66from __future__ import annotations
77
88import asyncio
9+ import enum
910import itertools
11+ import math
12+ from collections .abc import Iterable
1013from 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
1520from frequenz .client .common .microgrid .components import ComponentId
1621from google .protobuf .empty_pb2 import Empty
1722from typing_extensions import override
1823
1924from ._exception import ClientNotConnected
2025from ._microgrid_info import MicrogridInfo
2126from ._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
2331DEFAULT_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+ )
0 commit comments