Skip to content

Commit 416a8b0

Browse files
committed
WIP
1 parent cfdf92f commit 416a8b0

File tree

10 files changed

+408
-47
lines changed

10 files changed

+408
-47
lines changed

src/frequenz/client/microgrid/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
DEFAULT_CHANNEL_OPTIONS,
1212
DEFAULT_GRPC_CALL_TIMEOUT,
1313
MicrogridApiClient,
14+
Validity,
1415
)
1516
from ._delivery_area import DeliveryArea, EnergyMarketCodeType
1617
from ._exception import (
@@ -73,4 +74,5 @@
7374
"ServiceUnavailable",
7475
"UnknownError",
7576
"UnrecognizedGrpcStatus",
77+
"Validity",
7678
]

src/frequenz/client/microgrid/_client.py

Lines changed: 126 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@
55

66
from __future__ import annotations
77

8+
import enum
89
import math
910
from collections.abc import Iterable
1011
from dataclasses import replace
1112
from datetime import datetime, timedelta
1213
from typing import assert_never
1314

14-
from frequenz.api.common.v1.metrics import metric_sample_pb2
15+
from frequenz.api.common.v1.metrics import bounds_pb2, metric_sample_pb2
1516
from frequenz.api.common.v1.microgrid.components import components_pb2
1617
from frequenz.api.microgrid.v1 import microgrid_pb2, microgrid_pb2_grpc
1718
from frequenz.channels import Receiver
@@ -30,6 +31,7 @@
3031
from .component._connection_proto import component_connection_from_proto
3132
from .component._data_samples import ComponentDataSamples
3233
from .component._data_samples_proto import component_data_sample_from_proto
34+
from .metrics._bounds import Bounds
3335
from .metrics._metric import Metric
3436

3537
DEFAULT_GRPC_CALL_TIMEOUT = 60.0
@@ -285,7 +287,7 @@ async def set_component_power_active( # noqa: DOC502 (raises ApiClientError ind
285287
286288
Returns:
287289
The timestamp until which the given power command will stay in effect, or
288-
`None` if it was not provided.
290+
`None` if it was not provided by the server.
289291
290292
Raises:
291293
ApiClientError: If the are any errors communicating with the Microgrid API,
@@ -368,7 +370,7 @@ async def set_component_power_reactive( # noqa: DOC502 (raises ApiClientError i
368370
369371
Returns:
370372
The timestamp until which the given power command will stay in effect, or
371-
`None` if it was not provided.
373+
`None` if it was not provided by the server.
372374
373375
Raises:
374376
ApiClientError: If the are any errors communicating with the Microgrid API,
@@ -398,6 +400,103 @@ async def set_component_power_reactive( # noqa: DOC502 (raises ApiClientError i
398400

399401
return None
400402

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+
401500
# noqa: DOC502 (Raises ApiClientError indirectly)
402501
async def receive_component_data_samples_stream(
403502
self,
@@ -461,6 +560,30 @@ async def receive_component_data_samples_stream(
461560
return broadcaster.new_receiver(maxsize=buffer_size)
462561

463562

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+
464587
def _get_component_id(component: ComponentId | Component) -> int:
465588
"""Get the component ID from a component or component ID."""
466589
match component:

src/frequenz/client/microgrid/component/__init__.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@
1515
)
1616
from ._category import ComponentCategory
1717
from ._chp import Chp
18-
from ._component import ComponentTypes
18+
from ._component import (
19+
ComponentTypes,
20+
ProblematicComponentTypes,
21+
UnrecognizedComponentTypes,
22+
UnspecifiedComponentTypes,
23+
)
1924
from ._connection import ComponentConnection
2025
from ._converter import Converter
2126
from ._crypto_miner import CryptoMiner
@@ -48,7 +53,6 @@
4853
from ._problematic import (
4954
MismatchedCategoryComponent,
5055
ProblematicComponent,
51-
ProblematicComponentTypes,
5256
UnrecognizedComponent,
5357
UnspecifiedComponent,
5458
)
@@ -98,10 +102,12 @@
98102
"SolarInverter",
99103
"UnrecognizedBattery",
100104
"UnrecognizedComponent",
105+
"UnrecognizedComponentTypes",
101106
"UnrecognizedEvCharger",
102107
"UnrecognizedInverter",
103108
"UnspecifiedBattery",
104109
"UnspecifiedComponent",
110+
"UnspecifiedComponentTypes",
105111
"UnspecifiedEvCharger",
106112
"UnspecifiedInverter",
107113
"VoltageTransformer",

src/frequenz/client/microgrid/component/_base.py

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import dataclasses
77
import logging
8+
from collections.abc import Mapping
89
from functools import cached_property
910
from typing import Any, Self
1011

@@ -28,27 +29,68 @@ class Component: # pylint: disable=too-many-instance-attributes
2829
microgrid_id: MicrogridId
2930
"""The ID of the microgrid this component belongs to."""
3031

31-
name: str | None
32-
"""The name of this component."""
33-
3432
category: ComponentCategory | int
35-
"""The category of this component."""
33+
"""The category of this component.
34+
35+
Note:
36+
This should not be used normally, you should test if a component
37+
[`isinstance`][] of a concrete component class instead.
38+
39+
It is only provided for using with a newer version of the API where the client
40+
doesn't know about a new category yet (i.e. for use with
41+
[`UnrecognizedComponent`][frequenz.client.microgrid.component.UnrecognizedComponent])
42+
and in case some low level code needs to know the category of a component.
43+
"""
44+
45+
status: ComponentStatus | int = ComponentStatus.ACTIVE
46+
"""The status of this component.
3647
37-
manufacturer: str | None
48+
Note:
49+
This should not be used normally, you should test if a component is active
50+
by checking the [`active`][frequenz.client.microgrid.component.Component.active]
51+
property instead.
52+
"""
53+
54+
name: str | None = None
55+
"""The name of this component."""
56+
57+
manufacturer: str | None = None
3858
"""The manufacturer of this component."""
3959

40-
model_name: str | None
60+
model_name: str | None = None
4161
"""The model name of this component."""
4262

43-
status: ComponentStatus | int
44-
"""The status of this component."""
45-
46-
operational_lifetime: Lifetime
63+
operational_lifetime: Lifetime = dataclasses.field(default_factory=Lifetime)
4764
"""The operational lifetime of this component."""
4865

49-
rated_bounds: dict[Metric | int, Bounds]
66+
rated_bounds: Mapping[Metric | int, Bounds] = dataclasses.field(
67+
default_factory=dict,
68+
# dict is not hashable, so we don't use this field to calculate the hash. This
69+
# shouldn't be a problem since it is very unlikely that two components with all
70+
# other attributes being equal would have different category specific metadata,
71+
# so hash collisions should be still very unlikely.
72+
# TODO: Test hashing components
73+
hash=False,
74+
)
5075
"""List of rated bounds present for the component identified by Metric."""
5176

77+
category_specific_metadata: Mapping[str, Any] = dataclasses.field(
78+
default_factory=dict,
79+
# dict is not hashable, so we don't use this field to calculate the hash. This
80+
# shouldn't be a problem since it is very unlikely that two components with all
81+
# other attributes being equal would have different category specific metadata,
82+
# so hash collisions should be still very unlikely.
83+
hash=False,
84+
)
85+
"""The category specific metadata of this component.
86+
87+
Note:
88+
This should not be used normally, it is only useful when accessing a newer
89+
version of the API where the client doesn't know about the new metadata fields
90+
yet (i.e. for use with
91+
[`UnrecognizedComponent`][frequenz.client.microgrid.component.UnrecognizedComponent]).
92+
"""
93+
5294
# pylint: disable-next=unused-argument
5395
def __new__(cls, *args: Any, **kwargs: Any) -> Self:
5496
"""Prevent instantiation of this class."""

0 commit comments

Comments
 (0)