55
66from __future__ import annotations
77
8+ import math
89from collections .abc import Iterable
910from dataclasses import replace
11+ from datetime import datetime , timedelta
1012from typing import Any , assert_never
1113
1214from frequenz .api .common .v1 .microgrid .components import components_pb2
1315from frequenz .api .microgrid .v1 import microgrid_pb2 , microgrid_pb2_grpc
14- from frequenz .client .base import channel , client , retry , streaming
16+ from frequenz .client .base import channel , client , conversion , retry , streaming
1517from google .protobuf .empty_pb2 import Empty
1618
1719from ._exception import ClientNotConnected
@@ -226,6 +228,85 @@ async def list_connections( # noqa: DOC502 (raises ApiClientError indirectly)
226228
227229 return map (component_connection_from_proto , connection_list .connections )
228230
231+ async def set_component_power_active ( # noqa: DOC502 (raises ApiClientError indirectly)
232+ self ,
233+ component : ComponentId | Component ,
234+ power : float ,
235+ * ,
236+ request_lifetime : timedelta | None = None ,
237+ validate_arguments : bool = True ,
238+ ) -> datetime | None :
239+ """Set the active power output of a component.
240+
241+ The power output can be negative or positive, depending on whether the component
242+ is supposed to be discharging or charging, respectively.
243+
244+ The power output is specified in watts.
245+
246+ The return value is the timestamp until which the given power command will
247+ stay in effect. After this timestamp, the component's active power will be
248+ set to 0, if the API receives no further command to change it before then.
249+ By default, this timestamp will be set to the current time plus 60 seconds.
250+
251+ Note:
252+ The target component may have a resolution of more than 1 W. E.g., an
253+ inverter may have a resolution of 88 W. In such cases, the magnitude of
254+ power will be floored to the nearest multiple of the resolution.
255+
256+ Performs the following sequence actions for the following component
257+ categories:
258+
259+ * Inverter: Sends the discharge command to the inverter, making it deliver
260+ AC power.
261+ * TODO document missing.
262+
263+ Args:
264+ component: The component to set the output active power of.
265+ power: The output active power level, in watts. Negative values are for
266+ discharging, and positive values are for charging.
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 the 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 .SetComponentPowerActive (
295+ microgrid_pb2 .SetComponentPowerActiveRequest (
296+ component_id = _get_component_id (component ),
297+ power = power ,
298+ request_lifetime = lifetime_seconds ,
299+ ),
300+ timeout = int (DEFAULT_GRPC_CALL_TIMEOUT ),
301+ ),
302+ method_name = "SetComponentPowerActive" ,
303+ )
304+
305+ if response .HasField ("valid_until" ):
306+ return conversion .to_datetime (response .valid_until )
307+
308+ return None
309+
229310
230311def _get_component_id (component : ComponentId | Component ) -> int :
231312 """Get the component ID from a component or component ID."""
@@ -249,3 +330,21 @@ def _get_category_value(
249330 return components_pb2 .ComponentCategory .ValueType (category )
250331 case unexpected :
251332 assert_never (unexpected )
333+
334+
335+ def _delta_to_seconds (delta : timedelta | None ) -> int | None :
336+ """Convert a `timedelta` to seconds (or `None` if `None`)."""
337+ return round (delta .total_seconds ()) if delta is not None else None
338+
339+
340+ def _validate_set_power_args (* , power : float , request_lifetime : int | None ) -> None :
341+ """Validate the request lifetime."""
342+ if math .isnan (power ):
343+ raise ValueError ("power cannot be NaN" )
344+ if request_lifetime is not None :
345+ minimum_lifetime = 10 # 10 seconds
346+ maximum_lifetime = 900 # 15 minutes
347+ if not minimum_lifetime <= request_lifetime <= maximum_lifetime :
348+ raise ValueError (
349+ "request_lifetime must be between 10 seconds and 15 minutes"
350+ )
0 commit comments