-
Notifications
You must be signed in to change notification settings - Fork 5
Add gRPC calls to control components #158
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1c8a132
0590d81
ad03c63
c305970
e416a80
02be569
8460b64
c3ad475
f585f0c
3c69ffe
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,19 +6,27 @@ | |
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| import enum | ||
| import itertools | ||
| import math | ||
| from collections.abc import Iterable | ||
| from dataclasses import replace | ||
| from typing import Any | ||
| from datetime import datetime, timedelta | ||
| from typing import Any, assert_never | ||
|
|
||
| from frequenz.api.microgrid.v1 import microgrid_pb2_grpc | ||
| from frequenz.client.base import channel, client, retry, streaming | ||
| from frequenz.api.common.v1.metrics import bounds_pb2, metric_sample_pb2 | ||
| from frequenz.api.microgrid.v1 import microgrid_pb2, microgrid_pb2_grpc | ||
| from frequenz.client.base import channel, client, conversion, retry, streaming | ||
| from frequenz.client.common.microgrid.components import ComponentId | ||
| from google.protobuf.empty_pb2 import Empty | ||
| from typing_extensions import override | ||
|
|
||
| from ._exception import ClientNotConnected | ||
| from ._microgrid_info import MicrogridInfo | ||
| from ._microgrid_info_proto import microgrid_info_from_proto | ||
| from .component._component import Component | ||
| from .metrics._bounds import Bounds | ||
| from .metrics._metric import Metric | ||
|
|
||
| DEFAULT_GRPC_CALL_TIMEOUT = 60.0 | ||
| """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) | |
| The information about the local microgrid. | ||
|
|
||
| Raises: | ||
| ApiClientError: If the are any errors communicating with the Microgrid API, | ||
| ApiClientError: If there are any errors communicating with the Microgrid API, | ||
| most likely a subclass of | ||
| [GrpcError][frequenz.client.microgrid.GrpcError]. | ||
| """ | ||
|
|
@@ -153,3 +161,314 @@ async def get_microgrid_info( # noqa: DOC502 (raises ApiClientError indirectly) | |
| ) | ||
|
|
||
| return microgrid_info_from_proto(microgrid.microgrid) | ||
|
|
||
| async def set_component_power_active( # noqa: DOC502 (raises ApiClientError indirectly) | ||
| self, | ||
| component: ComponentId | Component, | ||
| power: float, | ||
| *, | ||
| request_lifetime: timedelta | None = None, | ||
| validate_arguments: bool = True, | ||
| ) -> datetime | None: | ||
| """Set the active power output of a component. | ||
|
|
||
| The power output can be negative or positive, depending on whether the component | ||
| is supposed to be discharging or charging, respectively. | ||
|
|
||
| The power output is specified in watts. | ||
|
|
||
| The return value is the timestamp until which the given power command will | ||
| stay in effect. After this timestamp, the component's active power will be | ||
| set to 0, if the API receives no further command to change it before then. | ||
| By default, this timestamp will be set to the current time plus 60 seconds. | ||
|
|
||
| Note: | ||
| The target component may have a resolution of more than 1 W. E.g., an | ||
| inverter may have a resolution of 88 W. In such cases, the magnitude of | ||
| power will be floored to the nearest multiple of the resolution. | ||
|
|
||
| Args: | ||
| component: The component to set the output active power of. | ||
| power: The output active power level, in watts. Negative values are for | ||
| discharging, and positive values are for charging. | ||
| request_lifetime: The duration, until which the request will stay in effect. | ||
| This duration has to be between 10 seconds and 15 minutes (including | ||
| both limits), otherwise the request will be rejected. It has | ||
| a resolution of a second, so fractions of a second will be rounded for | ||
| `timedelta` objects, and it is interpreted as seconds for `int` objects. | ||
| If not provided, it usually defaults to 60 seconds. | ||
| validate_arguments: Whether to validate the arguments before sending the | ||
| request. If `True` a `ValueError` will be raised if an argument is | ||
| invalid without even sending the request to the server, if `False`, the | ||
| request will be sent without validation. | ||
|
|
||
| Returns: | ||
| The timestamp until which the given power command will stay in effect, or | ||
| `None` if it was not provided by the server. | ||
|
|
||
| Raises: | ||
| ApiClientError: If there are any errors communicating with the Microgrid API, | ||
| most likely a subclass of | ||
| [GrpcError][frequenz.client.microgrid.GrpcError]. | ||
| """ | ||
| lifetime_seconds = _delta_to_seconds(request_lifetime) | ||
|
|
||
| if validate_arguments: | ||
| _validate_set_power_args(power=power, request_lifetime=lifetime_seconds) | ||
|
|
||
| response = await client.call_stub_method( | ||
| self, | ||
| lambda: self.stub.SetComponentPowerActive( | ||
| microgrid_pb2.SetComponentPowerActiveRequest( | ||
| component_id=_get_component_id(component), | ||
| power=power, | ||
| request_lifetime=lifetime_seconds, | ||
| ), | ||
| timeout=DEFAULT_GRPC_CALL_TIMEOUT, | ||
| ), | ||
| method_name="SetComponentPowerActive", | ||
| ) | ||
|
|
||
| if response.HasField("valid_until"): | ||
| return conversion.to_datetime(response.valid_until) | ||
|
|
||
| return None | ||
|
|
||
| async def set_component_power_reactive( # noqa: DOC502 (raises ApiClientError indirectly) | ||
| self, | ||
| component: ComponentId | Component, | ||
| power: float, | ||
| *, | ||
| request_lifetime: timedelta | None = None, | ||
| validate_arguments: bool = True, | ||
| ) -> datetime | None: | ||
| """Set the reactive power output of a component. | ||
|
|
||
| We follow the polarity specified in the IEEE 1459-2010 standard | ||
| definitions, where: | ||
|
|
||
| - Positive reactive is inductive (current is lagging the voltage) | ||
| - Negative reactive is capacitive (current is leading the voltage) | ||
|
|
||
| The power output is specified in VAr. | ||
|
|
||
| The return value is the timestamp until which the given power command will | ||
| stay in effect. After this timestamp, the component's reactive power will | ||
| be set to 0, if the API receives no further command to change it before | ||
| then. By default, this timestamp will be set to the current time plus 60 | ||
| seconds. | ||
|
|
||
| Note: | ||
| The target component may have a resolution of more than 1 VAr. E.g., an | ||
| inverter may have a resolution of 88 VAr. In such cases, the magnitude of | ||
| power will be floored to the nearest multiple of the resolution. | ||
|
|
||
| Args: | ||
| component: The component to set the output reactive power of. | ||
| power: The output reactive power level, in VAr. The standard of polarity is | ||
| as per the IEEE 1459-2010 standard definitions: positive reactive is | ||
| inductive (current is lagging the voltage); negative reactive is | ||
| capacitive (current is leading the voltage). | ||
| request_lifetime: The duration, until which the request will stay in effect. | ||
| This duration has to be between 10 seconds and 15 minutes (including | ||
| both limits), otherwise the request will be rejected. It has | ||
| a resolution of a second, so fractions of a second will be rounded for | ||
| `timedelta` objects, and it is interpreted as seconds for `int` objects. | ||
| If not provided, it usually defaults to 60 seconds. | ||
| validate_arguments: Whether to validate the arguments before sending the | ||
| request. If `True` a `ValueError` will be raised if an argument is | ||
| invalid without even sending the request to the server, if `False`, the | ||
| request will be sent without validation. | ||
|
|
||
| Returns: | ||
| The timestamp until which the given power command will stay in effect, or | ||
| `None` if it was not provided by the server. | ||
|
|
||
| Raises: | ||
| ApiClientError: If there are any errors communicating with the Microgrid API, | ||
| most likely a subclass of | ||
| [GrpcError][frequenz.client.microgrid.GrpcError]. | ||
| """ | ||
| lifetime_seconds = _delta_to_seconds(request_lifetime) | ||
|
|
||
| if validate_arguments: | ||
| _validate_set_power_args(power=power, request_lifetime=lifetime_seconds) | ||
|
|
||
| response = await client.call_stub_method( | ||
| self, | ||
| lambda: self.stub.SetComponentPowerReactive( | ||
| microgrid_pb2.SetComponentPowerReactiveRequest( | ||
| component_id=_get_component_id(component), | ||
| power=power, | ||
| request_lifetime=lifetime_seconds, | ||
| ), | ||
| timeout=DEFAULT_GRPC_CALL_TIMEOUT, | ||
| ), | ||
| method_name="SetComponentPowerReactive", | ||
| ) | ||
|
|
||
| if response.HasField("valid_until"): | ||
| return conversion.to_datetime(response.valid_until) | ||
|
|
||
| return None | ||
|
|
||
| async def add_component_bounds( # noqa: DOC502 (Raises ApiClientError indirectly) | ||
| self, | ||
| component: ComponentId | Component, | ||
| target: Metric | int, | ||
| bounds: Iterable[Bounds], | ||
| *, | ||
| validity: Validity | None = None, | ||
| ) -> datetime | None: | ||
| """Add inclusion bounds for a given metric of a given component. | ||
|
|
||
| The bounds are used to define the acceptable range of values for a metric | ||
| of a component. The added bounds are kept only temporarily, and removed | ||
| automatically after some expiry time. | ||
|
|
||
| Inclusion bounds give the range that the system will try to keep the | ||
| metric within. If the metric goes outside of these bounds, the system will | ||
| try to bring it back within the bounds. | ||
| If the bounds for a metric are `[[lower_1, upper_1], [lower_2, upper_2]]`, | ||
| then this metric's `value` needs to comply with the constraints `lower_1 <= | ||
| value <= upper_1` OR `lower_2 <= value <= upper_2`. | ||
|
|
||
| If multiple inclusion bounds have been provided for a metric, then the | ||
| overlapping bounds are merged into a single bound, and non-overlapping | ||
| bounds are kept separate. | ||
|
|
||
| Example: | ||
| If the bounds are [[0, 10], [5, 15], [20, 30]], then the resulting bounds | ||
| will be [[0, 15], [20, 30]]. | ||
|
|
||
| The following diagram illustrates how bounds are applied: | ||
|
|
||
| ``` | ||
| lower_1 upper_1 | ||
| <----|========|--------|========|--------> | ||
| lower_2 upper_2 | ||
| ``` | ||
|
|
||
| The bounds in this example are `[[lower_1, upper_1], [lower_2, upper_2]]`. | ||
|
|
||
| ``` | ||
| ---- values here are considered out of range. | ||
| ==== values here are considered within range. | ||
| ``` | ||
|
|
||
| Note: | ||
| For power metrics, regardless of the bounds, 0W is always allowed. | ||
|
|
||
| Args: | ||
| component: The component to add bounds to. | ||
| target: The target metric whose bounds have to be added. | ||
| bounds: The bounds to add to the target metric. Overlapping pairs of bounds | ||
| are merged into a single pair of bounds, and non-overlapping ones are | ||
| kept separated. | ||
| validity: The duration for which the given bounds will stay in effect. | ||
| If `None`, then the bounds will be removed after some default time | ||
| decided by the server, typically 5 seconds. | ||
|
|
||
| The duration for which the bounds are valid. If not provided, the | ||
| bounds are considered to be valid indefinitely. | ||
|
|
||
| Returns: | ||
| The timestamp until which the given bounds will stay in effect, or `None` if | ||
| if it was not provided by the server. | ||
|
|
||
| Raises: | ||
| ApiClientError: If there are any errors communicating with the Microgrid API, | ||
| most likely a subclass of | ||
| [GrpcError][frequenz.client.microgrid.GrpcError]. | ||
| """ | ||
| extra_args = {} | ||
| if validity is not None: | ||
| extra_args["validity_duration"] = validity.value | ||
| response = await client.call_stub_method( | ||
| self, | ||
| lambda: self.stub.AddComponentBounds( | ||
| microgrid_pb2.AddComponentBoundsRequest( | ||
| component_id=_get_component_id(component), | ||
| target_metric=_get_metric_value(target), | ||
| bounds=( | ||
| bounds_pb2.Bounds( | ||
| lower=bound.lower, | ||
| upper=bound.upper, | ||
| ) | ||
| for bound in bounds | ||
| ), | ||
| **extra_args, | ||
| ), | ||
| timeout=DEFAULT_GRPC_CALL_TIMEOUT, | ||
| ), | ||
| method_name="AddComponentBounds", | ||
| ) | ||
|
|
||
| if response.HasField("ts"): | ||
| return conversion.to_datetime(response.ts) | ||
|
|
||
| return None | ||
|
|
||
|
|
||
| class Validity(enum.Enum): | ||
| """The duration for which a given list of bounds will stay in effect.""" | ||
|
|
||
| FIVE_SECONDS = ( | ||
| microgrid_pb2.ComponentBoundsValidityDuration.COMPONENT_BOUNDS_VALIDITY_DURATION_5_SECONDS | ||
| ) | ||
| """The bounds will stay in effect for 5 seconds.""" | ||
|
|
||
| ONE_MINUTE = ( | ||
| microgrid_pb2.ComponentBoundsValidityDuration.COMPONENT_BOUNDS_VALIDITY_DURATION_1_MINUTE | ||
| ) | ||
| """The bounds will stay in effect for 1 minute.""" | ||
|
|
||
| FIVE_MINUTES = ( | ||
| microgrid_pb2.ComponentBoundsValidityDuration.COMPONENT_BOUNDS_VALIDITY_DURATION_5_MINUTES | ||
| ) | ||
| """The bounds will stay in effect for 5 minutes.""" | ||
|
|
||
| FIFTEEN_MINUTES = ( | ||
| microgrid_pb2.ComponentBoundsValidityDuration.COMPONENT_BOUNDS_VALIDITY_DURATION_15_MINUTES | ||
| ) | ||
| """The bounds will stay in effect for 15 minutes.""" | ||
|
|
||
|
|
||
| def _get_component_id(component: ComponentId | Component) -> int: | ||
| """Get the component ID from a component or component ID.""" | ||
| match component: | ||
| case ComponentId(): | ||
| return int(component) | ||
| case Component(): | ||
| return int(component.id) | ||
| case unexpected: | ||
| assert_never(unexpected) | ||
|
|
||
|
|
||
| def _get_metric_value(metric: Metric | int) -> metric_sample_pb2.Metric.ValueType: | ||
| """Get the metric ID from a metric or metric ID.""" | ||
| match metric: | ||
| case Metric(): | ||
| return metric_sample_pb2.Metric.ValueType(metric.value) | ||
| case int(): | ||
| return metric_sample_pb2.Metric.ValueType(metric) | ||
| case unexpected: | ||
| assert_never(unexpected) | ||
|
|
||
|
|
||
| def _delta_to_seconds(delta: timedelta | None) -> int | None: | ||
| """Convert a `timedelta` to seconds (or `None` if `None`).""" | ||
| return round(delta.total_seconds()) if delta is not None else None | ||
|
Comment on lines
+459
to
+461
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This returns There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. True. Interesting that .... looking 🔍 ...... It is not reported because it does return class _SupportsRound1(Protocol[_T_co]):
def __round__(self) -> _T_co: ...
class _SupportsRound2(Protocol[_T_co]):
def __round__(self, ndigits: int, /) -> _T_co: ...
@overload
def round(number: _SupportsRound1[_T], ndigits: None = None) -> _T: ...
@overload
def round(number: _SupportsRound2[_T], ndigits: SupportsIndex) -> _T: ...and for @overload
def __round__(self, ndigits: None = None, /) -> int: ...
@overload
def __round__(self, ndigits: SupportsIndex, /) -> float: ...💥 |
||
|
|
||
|
|
||
| def _validate_set_power_args(*, power: float, request_lifetime: int | None) -> None: | ||
| """Validate the request lifetime.""" | ||
| if math.isnan(power): | ||
| raise ValueError("power cannot be NaN") | ||
| if request_lifetime is not None: | ||
| minimum_lifetime = 10 # 10 seconds | ||
| maximum_lifetime = 900 # 15 minutes | ||
| if not minimum_lifetime <= request_lifetime <= maximum_lifetime: | ||
| raise ValueError( | ||
| "request_lifetime must be between 10 seconds and 15 minutes" | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| # License: MIT | ||
| # Copyright © 2024 Frequenz Energy-as-a-Service GmbH | ||
|
|
||
| """Loading of Lifetime objects from protobuf messages.""" | ||
|
|
||
| from frequenz.api.common.v1.microgrid import lifetime_pb2 | ||
| from frequenz.client.base.conversion import to_datetime | ||
|
|
||
| from ._lifetime import Lifetime | ||
|
|
||
|
|
||
| def lifetime_from_proto( | ||
| message: lifetime_pb2.Lifetime, | ||
| ) -> Lifetime: | ||
| """Create a [`Lifetime`][frequenz.client.microgrid.Lifetime] from a protobuf message.""" | ||
| start = ( | ||
| to_datetime(message.start_timestamp) | ||
| if message.HasField("start_timestamp") | ||
| else None | ||
| ) | ||
| end = ( | ||
| to_datetime(message.end_timestamp) | ||
| if message.HasField("end_timestamp") | ||
| else None | ||
| ) | ||
| return Lifetime(start=start, end=end) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The API also specifies that there can't be more than two non-overlapping bounds, right? In that case, it would be nice if the docs for the
add_component_boundsmethod specify that users should be careful not to add more than two non-overlapping bounds.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure what do you mean by this? It says explicitly there that overlapping bounds will be merged.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have this vague memory that these were supposed to exactly replace inclusion/exclusion bounds, so there will not be more than two non-overlapping bounds in the new scheme, and that that was going to be codified in the docs.
But that doesn't appear to be the case, so nvm.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oh, yeah, I remember that too, not sure what's the current state of this, if that limitation still holds or have been lifted. Since one can add bounds, I guess it is not true anymore. @tiyash-basu-frequenz ?