66from __future__ import annotations
77
88import asyncio
9+ import enum
910import itertools
1011import math
12+ from collections .abc import Iterable
1113from dataclasses import replace
1214from datetime import datetime , timedelta
1315from typing import Any , assert_never
1416
17+ from frequenz .api .common .v1 .metrics import bounds_pb2 , metric_sample_pb2
1518from frequenz .api .microgrid .v1 import microgrid_pb2 , microgrid_pb2_grpc
1619from frequenz .client .base import channel , client , conversion , retry , streaming
1720from frequenz .client .common .microgrid .components import ComponentId
2225from ._microgrid_info import MicrogridInfo
2326from ._microgrid_info_proto import microgrid_info_from_proto
2427from .component ._component import Component
28+ from .metrics ._bounds import Bounds
29+ from .metrics ._metric import Metric
2530
2631DEFAULT_GRPC_CALL_TIMEOUT = 60.0
2732"""The default timeout for gRPC calls made by this client (in seconds)."""
@@ -307,6 +312,127 @@ async def set_component_power_reactive( # noqa: DOC502 (raises ApiClientError i
307312
308313 return None
309314
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+
310436
311437def _get_component_id (component : ComponentId | Component ) -> int :
312438 """Get the component ID from a component or component ID."""
@@ -319,6 +445,17 @@ def _get_component_id(component: ComponentId | Component) -> int:
319445 assert_never (unexpected )
320446
321447
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+
322459def _delta_to_seconds (delta : timedelta | None ) -> int | None :
323460 """Convert a `timedelta` to seconds (or `None` if `None`)."""
324461 return round (delta .total_seconds ()) if delta is not None else None
0 commit comments