|
5 | 5 |
|
6 | 6 | from __future__ import annotations |
7 | 7 |
|
| 8 | +import enum |
8 | 9 | import math |
9 | 10 | from collections.abc import Iterable |
10 | 11 | from dataclasses import replace |
11 | 12 | from datetime import datetime, timedelta |
12 | 13 | from typing import assert_never |
13 | 14 |
|
14 | | -from frequenz.api.common.v1.metrics import metric_sample_pb2 |
| 15 | +from frequenz.api.common.v1.metrics import bounds_pb2, metric_sample_pb2 |
15 | 16 | from frequenz.api.common.v1.microgrid.components import components_pb2 |
16 | 17 | from frequenz.api.microgrid.v1 import microgrid_pb2, microgrid_pb2_grpc |
17 | 18 | from frequenz.channels import Receiver |
|
30 | 31 | from .component._connection_proto import component_connection_from_proto |
31 | 32 | from .component._data_samples import ComponentDataSamples |
32 | 33 | from .component._data_samples_proto import component_data_samples_from_proto |
| 34 | +from .metrics._bounds import Bounds |
33 | 35 | from .metrics._metric import Metric |
34 | 36 |
|
35 | 37 | DEFAULT_GRPC_CALL_TIMEOUT = 60.0 |
@@ -398,6 +400,103 @@ async def set_component_power_reactive( # noqa: DOC502 (raises ApiClientError i |
398 | 400 |
|
399 | 401 | return None |
400 | 402 |
|
| 403 | + async def add_component_bounds( # noqa: DOC502 (Raises ApiClientError indirectly) |
| 404 | + self, |
| 405 | + component: ComponentId | Component, |
| 406 | + target: Metric | int, |
| 407 | + bounds: Iterable[Bounds], |
| 408 | + *, |
| 409 | + validity: Validity | None = None, |
| 410 | + ) -> datetime | None: |
| 411 | + """Add inclusion bounds for a given metric of a given component. |
| 412 | +
|
| 413 | + The bounds are used to define the acceptable range of values for a metric |
| 414 | + of a component. The added bounds are kept only temporarily, and removed |
| 415 | + automatically after some expiry time. |
| 416 | +
|
| 417 | + Inclusion bounds give the range that the system will try to keep the |
| 418 | + metric within. If the metric goes outside of these bounds, the system will |
| 419 | + try to bring it back within the bounds. |
| 420 | + If the bounds for a metric are `[[lower_1, upper_1], [lower_2, upper_2]]`, |
| 421 | + then this metric's `value` needs to comply with the constraints `lower_1 <= |
| 422 | + value <= upper_1` OR `lower_2 <= value <= upper_2`. |
| 423 | +
|
| 424 | + If multiple inclusion bounds have been provided for a metric, then the |
| 425 | + overlapping bounds are merged into a single bound, and non-overlapping |
| 426 | + bounds are kept separate. |
| 427 | +
|
| 428 | + Example: |
| 429 | + If the bounds are [[0, 10], [5, 15], [20, 30]], then the resulting bounds |
| 430 | + will be [[0, 15], [20, 30]]. |
| 431 | +
|
| 432 | + The following diagram illustrates how bounds are applied: |
| 433 | +
|
| 434 | + ``` |
| 435 | + lower_1 upper_1 |
| 436 | + <----|========|--------|========|--------> |
| 437 | + lower_2 upper_2 |
| 438 | + ``` |
| 439 | +
|
| 440 | + The bounds in this example are `[[lower_1, upper_1], [lower_2, upper_2]]`. |
| 441 | +
|
| 442 | + ``` |
| 443 | + ---- values here are considered out of range. |
| 444 | + ==== values here are considered within range. |
| 445 | + ``` |
| 446 | +
|
| 447 | + Note: |
| 448 | + For power metrics, regardless of the bounds, 0W is always allowed. |
| 449 | +
|
| 450 | + Args: |
| 451 | + component: The component to add bounds to. |
| 452 | + target: The target metric whose bounds have to be added. |
| 453 | + bounds: The bounds to add to the target metric. Overlapping pairs of bounds |
| 454 | + are merged into a single pair of bounds, and non-overlapping ones are |
| 455 | + kept separated. |
| 456 | + validity: The duration for which the given bounds will stay in effect. |
| 457 | + If `None`, then the bounds will be removed after some default time |
| 458 | + decided by the server, typically 5 seconds. |
| 459 | +
|
| 460 | + The duration for which the bounds are valid. If not provided, the |
| 461 | + bounds are considered to be valid indefinitely. |
| 462 | +
|
| 463 | + Returns: |
| 464 | + The timestamp until which the given bounds will stay in effect, or `None` if |
| 465 | + if it was not provided by the server. |
| 466 | +
|
| 467 | + Raises: |
| 468 | + ApiClientError: If the are any errors communicating with the Microgrid API, |
| 469 | + most likely a subclass of |
| 470 | + [GrpcError][frequenz.client.microgrid.GrpcError]. |
| 471 | + """ |
| 472 | + extra_args = {} |
| 473 | + if validity is not None: |
| 474 | + extra_args["validity_duration"] = validity.value |
| 475 | + response = await client.call_stub_method( |
| 476 | + self, |
| 477 | + lambda: self.stub.AddComponentBounds( |
| 478 | + microgrid_pb2.AddComponentBoundsRequest( |
| 479 | + component_id=_get_component_id(component), |
| 480 | + target_metric=_get_metric_value(target), |
| 481 | + bounds=( |
| 482 | + bounds_pb2.Bounds( |
| 483 | + lower=bounds.lower, |
| 484 | + upper=bounds.upper, |
| 485 | + ) |
| 486 | + for bounds in bounds |
| 487 | + ), |
| 488 | + **extra_args, |
| 489 | + ), |
| 490 | + timeout=int(DEFAULT_GRPC_CALL_TIMEOUT), |
| 491 | + ), |
| 492 | + method_name="AddComponentBounds", |
| 493 | + ) |
| 494 | + |
| 495 | + if response.HasField("ts"): |
| 496 | + return conversion.to_datetime(response.ts) |
| 497 | + |
| 498 | + return None |
| 499 | + |
401 | 500 | # noqa: DOC502 (Raises ApiClientError indirectly) |
402 | 501 | async def receive_component_data_samples_stream( |
403 | 502 | self, |
@@ -461,6 +560,30 @@ async def receive_component_data_samples_stream( |
461 | 560 | return broadcaster.new_receiver(maxsize=buffer_size) |
462 | 561 |
|
463 | 562 |
|
| 563 | +class Validity(enum.Enum): |
| 564 | + """The duration for which a given list of bounds will stay in effect.""" |
| 565 | + |
| 566 | + FIVE_SECONDS = ( |
| 567 | + microgrid_pb2.ComponentBoundsValidityDuration.COMPONENT_BOUNDS_VALIDITY_DURATION_5_SECONDS |
| 568 | + ) |
| 569 | + """The bounds will stay in effect for 5 seconds.""" |
| 570 | + |
| 571 | + ONE_MINUTE = ( |
| 572 | + microgrid_pb2.ComponentBoundsValidityDuration.COMPONENT_BOUNDS_VALIDITY_DURATION_1_MINUTE |
| 573 | + ) |
| 574 | + """The bounds will stay in effect for 1 minute.""" |
| 575 | + |
| 576 | + FIVE_MINUTES = ( |
| 577 | + microgrid_pb2.ComponentBoundsValidityDuration.COMPONENT_BOUNDS_VALIDITY_DURATION_5_MINUTES |
| 578 | + ) |
| 579 | + """The bounds will stay in effect for 5 minutes.""" |
| 580 | + |
| 581 | + FIFTEEN_MINUTES = ( |
| 582 | + microgrid_pb2.ComponentBoundsValidityDuration.COMPONENT_BOUNDS_VALIDITY_DURATION_15_MINUTES |
| 583 | + ) |
| 584 | + """The bounds will stay in effect for 15 minutes.""" |
| 585 | + |
| 586 | + |
464 | 587 | def _get_component_id(component: ComponentId | Component) -> int: |
465 | 588 | """Get the component ID from a component or component ID.""" |
466 | 589 | match component: |
|
0 commit comments