33
44"""Client for requests to the Microgrid API."""
55
6+ import math
67from collections .abc import Iterable
78from dataclasses import replace
9+ from datetime import datetime , timedelta
810from typing import Any , assert_never
911
1012from frequenz .api .common .v1 .microgrid .components import components_pb2
1113from frequenz .api .microgrid .v1 import microgrid_pb2 , microgrid_pb2_grpc
12- from frequenz .client .base import channel , client , retry , streaming
14+ from frequenz .client .base import channel , client , conversion , retry , streaming
1315from google .protobuf .empty_pb2 import Empty
1416
1517from ._id import ComponentId
@@ -211,6 +213,85 @@ async def list_connections( # noqa: DOC502 (raises ApiClientError indirectly)
211213
212214 return map (component_connection_from_proto , connection_list .connections )
213215
216+ async def set_component_power_active ( # noqa: DOC502 (raises ApiClientError indirectly)
217+ self ,
218+ component : ComponentId | Component ,
219+ power : float ,
220+ * ,
221+ request_lifetime : timedelta | None = None ,
222+ validate_arguments : bool = True ,
223+ ) -> datetime | None :
224+ """Set the active power output of a component.
225+
226+ The power output can be negative or positive, depending on whether the component
227+ is supposed to be discharging or charging, respectively.
228+
229+ The power output is specified in watts.
230+
231+ The return value is the timestamp until which the given power command will
232+ stay in effect. After this timestamp, the component's active power will be
233+ set to 0, if the API receives no further command to change it before then.
234+ By default, this timestamp will be set to the current time plus 60 seconds.
235+
236+ Note:
237+ The target component may have a resolution of more than 1 W. E.g., an
238+ inverter may have a resolution of 88 W. In such cases, the magnitude of
239+ power will be floored to the nearest multiple of the resolution.
240+
241+ Performs the following sequence actions for the following component
242+ categories:
243+
244+ * Inverter: Sends the discharge command to the inverter, making it deliver
245+ AC power.
246+ * TODO document missing.
247+
248+ Args:
249+ component: The component to set the output active power of.
250+ power: The output active power level, in watts. Negative values are for
251+ discharging, and positive values are for charging.
252+ request_lifetime: The duration, until which the request will stay in effect.
253+ This duration has to be between 10 seconds and 15 minutes (including
254+ both limits), otherwise the request will be rejected. It has
255+ a resolution of a second, so fractions of a second will be rounded for
256+ `timedelta` objects, and it is interpreted as seconds for `int` objects.
257+ If not provided, it usually defaults to 60 seconds.
258+ validate_arguments: Whether to validate the arguments before sending the
259+ request. If `True` a `ValueError` will be raised if an argument is
260+ invalid without even sending the request to the server, if `False`, the
261+ request will be sent without validation.
262+
263+ Returns:
264+ The timestamp until which the given power command will stay in effect, or
265+ `None` if it was not provided.
266+
267+ Raises:
268+ ApiClientError: If the are any errors communicating with the Microgrid API,
269+ most likely a subclass of
270+ [GrpcError][frequenz.client.microgrid.GrpcError].
271+ """
272+ lifetime_seconds = _delta_to_seconds (request_lifetime )
273+
274+ if validate_arguments :
275+ _validate_set_power_args (power = power , request_lifetime = lifetime_seconds )
276+
277+ response = await client .call_stub_method (
278+ self ,
279+ lambda : self ._async_stub .SetComponentPowerActive (
280+ microgrid_pb2 .SetComponentPowerActiveRequest (
281+ component_id = _get_component_id (component ),
282+ power = power ,
283+ request_lifetime = lifetime_seconds ,
284+ ),
285+ timeout = int (DEFAULT_GRPC_CALL_TIMEOUT ),
286+ ),
287+ method_name = "SetComponentPowerActive" ,
288+ )
289+
290+ if response .HasField ("valid_until" ):
291+ return conversion .to_datetime (response .valid_until )
292+
293+ return None
294+
214295
215296def _get_component_id (component : ComponentId | Component ) -> int :
216297 """Get the component ID from a component or component ID."""
@@ -234,3 +315,21 @@ def _get_category_value(
234315 return components_pb2 .ComponentCategory .ValueType (category )
235316 case unexpected :
236317 assert_never (unexpected )
318+
319+
320+ def _delta_to_seconds (delta : timedelta | None ) -> int | None :
321+ """Convert a `timedelta` to seconds (or `None` if `None`)."""
322+ return round (delta .total_seconds ()) if delta is not None else None
323+
324+
325+ def _validate_set_power_args (* , power : float , request_lifetime : int | None ) -> None :
326+ """Validate the request lifetime."""
327+ if math .isnan (power ):
328+ raise ValueError ("power cannot be NaN" )
329+ if request_lifetime is not None :
330+ minimum_lifetime = 10 # 10 seconds
331+ maximum_lifetime = 900 # 15 minutes
332+ if not minimum_lifetime <= request_lifetime <= maximum_lifetime :
333+ raise ValueError (
334+ "request_lifetime must be between 10 seconds and 15 minutes"
335+ )
0 commit comments