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
@@ -224,6 +226,85 @@ async def list_connections( # noqa: DOC502 (raises ApiClientError indirectly)
224226
225227 return map (component_connection_from_proto , connection_list .connections )
226228
229+ async def set_component_power_active ( # noqa: DOC502 (raises ApiClientError indirectly)
230+ self ,
231+ component : ComponentId | Component ,
232+ power : float ,
233+ * ,
234+ request_lifetime : timedelta | None = None ,
235+ validate_arguments : bool = True ,
236+ ) -> datetime | None :
237+ """Set the active power output of a component.
238+
239+ The power output can be negative or positive, depending on whether the component
240+ is supposed to be discharging or charging, respectively.
241+
242+ The power output is specified in watts.
243+
244+ The return value is the timestamp until which the given power command will
245+ stay in effect. After this timestamp, the component's active power will be
246+ set to 0, if the API receives no further command to change it before then.
247+ By default, this timestamp will be set to the current time plus 60 seconds.
248+
249+ Note:
250+ The target component may have a resolution of more than 1 W. E.g., an
251+ inverter may have a resolution of 88 W. In such cases, the magnitude of
252+ power will be floored to the nearest multiple of the resolution.
253+
254+ Performs the following sequence actions for the following component
255+ categories:
256+
257+ * Inverter: Sends the discharge command to the inverter, making it deliver
258+ AC power.
259+ * TODO document missing.
260+
261+ Args:
262+ component: The component to set the output active power of.
263+ power: The output active power level, in watts. Negative values are for
264+ discharging, and positive values are for charging.
265+ request_lifetime: The duration, until which the request will stay in effect.
266+ This duration has to be between 10 seconds and 15 minutes (including
267+ both limits), otherwise the request will be rejected. It has
268+ a resolution of a second, so fractions of a second will be rounded for
269+ `timedelta` objects, and it is interpreted as seconds for `int` objects.
270+ If not provided, it usually defaults to 60 seconds.
271+ validate_arguments: Whether to validate the arguments before sending the
272+ request. If `True` a `ValueError` will be raised if an argument is
273+ invalid without even sending the request to the server, if `False`, the
274+ request will be sent without validation.
275+
276+ Returns:
277+ The timestamp until which the given power command will stay in effect, or
278+ `None` if it was not provided by the server.
279+
280+ Raises:
281+ ApiClientError: If the are any errors communicating with the Microgrid API,
282+ most likely a subclass of
283+ [GrpcError][frequenz.client.microgrid.GrpcError].
284+ """
285+ lifetime_seconds = _delta_to_seconds (request_lifetime )
286+
287+ if validate_arguments :
288+ _validate_set_power_args (power = power , request_lifetime = lifetime_seconds )
289+
290+ response = await client .call_stub_method (
291+ self ,
292+ lambda : self .stub .SetComponentPowerActive (
293+ microgrid_pb2 .SetComponentPowerActiveRequest (
294+ component_id = _get_component_id (component ),
295+ power = power ,
296+ request_lifetime = lifetime_seconds ,
297+ ),
298+ timeout = int (DEFAULT_GRPC_CALL_TIMEOUT ),
299+ ),
300+ method_name = "SetComponentPowerActive" ,
301+ )
302+
303+ if response .HasField ("valid_until" ):
304+ return conversion .to_datetime (response .valid_until )
305+
306+ return None
307+
227308
228309def _get_component_id (component : ComponentId | Component ) -> int :
229310 """Get the component ID from a component or component ID."""
@@ -247,3 +328,21 @@ def _get_category_value(
247328 return components_pb2 .ComponentCategory .ValueType (category )
248329 case unexpected :
249330 assert_never (unexpected )
331+
332+
333+ def _delta_to_seconds (delta : timedelta | None ) -> int | None :
334+ """Convert a `timedelta` to seconds (or `None` if `None`)."""
335+ return round (delta .total_seconds ()) if delta is not None else None
336+
337+
338+ def _validate_set_power_args (* , power : float , request_lifetime : int | None ) -> None :
339+ """Validate the request lifetime."""
340+ if math .isnan (power ):
341+ raise ValueError ("power cannot be NaN" )
342+ if request_lifetime is not None :
343+ minimum_lifetime = 10 # 10 seconds
344+ maximum_lifetime = 900 # 15 minutes
345+ if not minimum_lifetime <= request_lifetime <= maximum_lifetime :
346+ raise ValueError (
347+ "request_lifetime must be between 10 seconds and 15 minutes"
348+ )
0 commit comments