Skip to content

Commit d7c524d

Browse files
authored
Stream exclusion bounds from the battery pool (#537)
Based on #416, to be merged after upgrading the Microgrid API dependency to v0.15.1. Also closes #524
2 parents 609d9ab + 639394c commit d7c524d

File tree

7 files changed

+323
-111
lines changed

7 files changed

+323
-111
lines changed

RELEASE_NOTES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
- Upgrade to microgrid API v0.15.1. If you're using any of the lower level microgrid interfaces, you will need to upgrade your code.
1010
- The argument `conf_file` of the `ConfigManagingActor` constructor was renamed to `config_path`.
1111

12+
- The `BatteryPool.power_bounds` method now streams inclusion/exclusion bounds. The bounds are now represented by `Power` objects and not `float`s.
13+
1214
## New Features
1315

1416
- The `ConfigManagingActor` constructor now can accept a `pathlib.Path` as `config_path` too (before it accepted only a `str`).

src/frequenz/sdk/actor/_data_sourcing/microgrid_api_source.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,12 @@ def get_channel_name(self) -> str:
8585
ComponentMetricId.POWER_INCLUSION_LOWER_BOUND: lambda msg: (
8686
msg.power_inclusion_lower_bound
8787
),
88+
ComponentMetricId.POWER_EXCLUSION_LOWER_BOUND: lambda msg: (
89+
msg.power_exclusion_lower_bound
90+
),
91+
ComponentMetricId.POWER_EXCLUSION_UPPER_BOUND: lambda msg: (
92+
msg.power_exclusion_upper_bound
93+
),
8894
ComponentMetricId.POWER_INCLUSION_UPPER_BOUND: lambda msg: (
8995
msg.power_inclusion_upper_bound
9096
),
@@ -96,6 +102,12 @@ def get_channel_name(self) -> str:
96102
ComponentMetricId.ACTIVE_POWER_INCLUSION_LOWER_BOUND: lambda msg: (
97103
msg.active_power_inclusion_lower_bound
98104
),
105+
ComponentMetricId.ACTIVE_POWER_EXCLUSION_LOWER_BOUND: lambda msg: (
106+
msg.active_power_exclusion_lower_bound
107+
),
108+
ComponentMetricId.ACTIVE_POWER_EXCLUSION_UPPER_BOUND: lambda msg: (
109+
msg.active_power_exclusion_upper_bound
110+
),
99111
ComponentMetricId.ACTIVE_POWER_INCLUSION_UPPER_BOUND: lambda msg: (
100112
msg.active_power_inclusion_upper_bound
101113
),

src/frequenz/sdk/microgrid/component/_component.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,9 +145,13 @@ class ComponentMetricId(Enum):
145145
CAPACITY = "capacity"
146146

147147
POWER_INCLUSION_LOWER_BOUND = "power_inclusion_lower_bound"
148+
POWER_EXCLUSION_LOWER_BOUND = "power_exclusion_lower_bound"
149+
POWER_EXCLUSION_UPPER_BOUND = "power_exclusion_upper_bound"
148150
POWER_INCLUSION_UPPER_BOUND = "power_inclusion_upper_bound"
149151

150152
ACTIVE_POWER_INCLUSION_LOWER_BOUND = "active_power_inclusion_lower_bound"
153+
ACTIVE_POWER_EXCLUSION_LOWER_BOUND = "active_power_exclusion_lower_bound"
154+
ACTIVE_POWER_EXCLUSION_UPPER_BOUND = "active_power_exclusion_upper_bound"
151155
ACTIVE_POWER_INCLUSION_UPPER_BOUND = "active_power_inclusion_upper_bound"
152156

153157
TEMPERATURE = "temperature"

src/frequenz/sdk/timeseries/battery_pool/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33

44
"""Manage a pool of batteries."""
55

6-
from ._result_types import Bound, PowerMetrics
6+
from ._result_types import Bounds, PowerMetrics
77
from .battery_pool import BatteryPool
88

99
__all__ = [
1010
"BatteryPool",
1111
"PowerMetrics",
12-
"Bound",
12+
"Bounds",
1313
]

src/frequenz/sdk/timeseries/battery_pool/_metric_calculator.py

Lines changed: 121 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@
1212

1313
from ...microgrid import connection_manager
1414
from ...microgrid.component import ComponentCategory, ComponentMetricId, InverterType
15-
from ...timeseries import Energy, Percentage, Sample, Temperature
15+
from ...timeseries import Sample
16+
from .._quantities import Energy, Percentage, Power, Temperature
1617
from ._component_metrics import ComponentMetricsData
17-
from ._result_types import Bound, PowerMetrics
18+
from ._result_types import Bounds, PowerMetrics
1819

1920
_logger = logging.getLogger(__name__)
2021
_MIN_TIMESTAMP = datetime.min.replace(tzinfo=timezone.utc)
@@ -479,11 +480,15 @@ def __init__(
479480
super().__init__(used_batteries)
480481
self._battery_metrics = [
481482
ComponentMetricId.POWER_INCLUSION_LOWER_BOUND,
483+
ComponentMetricId.POWER_EXCLUSION_LOWER_BOUND,
484+
ComponentMetricId.POWER_EXCLUSION_UPPER_BOUND,
482485
ComponentMetricId.POWER_INCLUSION_UPPER_BOUND,
483486
]
484487

485488
self._inverter_metrics = [
486489
ComponentMetricId.ACTIVE_POWER_INCLUSION_LOWER_BOUND,
490+
ComponentMetricId.ACTIVE_POWER_EXCLUSION_LOWER_BOUND,
491+
ComponentMetricId.ACTIVE_POWER_EXCLUSION_UPPER_BOUND,
487492
ComponentMetricId.ACTIVE_POWER_INCLUSION_UPPER_BOUND,
488493
]
489494

@@ -514,6 +519,84 @@ def inverter_metrics(self) -> Mapping[int, list[ComponentMetricId]]:
514519
"""
515520
return {cid: self._inverter_metrics for cid in set(self._bat_inv_map.values())}
516521

522+
def _fetch_inclusion_bounds(
523+
self,
524+
battery_id: int,
525+
inverter_id: int,
526+
metrics_data: dict[int, ComponentMetricsData],
527+
) -> tuple[datetime, list[float], list[float]]:
528+
timestamp = _MIN_TIMESTAMP
529+
inclusion_lower_bounds: list[float] = []
530+
inclusion_upper_bounds: list[float] = []
531+
532+
# Inclusion upper and lower bounds are not related.
533+
# If one is missing, then we can still use the other.
534+
if battery_id in metrics_data:
535+
data = metrics_data[battery_id]
536+
value = data.get(ComponentMetricId.POWER_INCLUSION_UPPER_BOUND)
537+
if value is not None:
538+
timestamp = max(timestamp, data.timestamp)
539+
inclusion_upper_bounds.append(value)
540+
541+
value = data.get(ComponentMetricId.POWER_INCLUSION_LOWER_BOUND)
542+
if value is not None:
543+
timestamp = max(timestamp, data.timestamp)
544+
inclusion_lower_bounds.append(value)
545+
546+
if inverter_id in metrics_data:
547+
data = metrics_data[inverter_id]
548+
549+
value = data.get(ComponentMetricId.ACTIVE_POWER_INCLUSION_UPPER_BOUND)
550+
if value is not None:
551+
timestamp = max(data.timestamp, timestamp)
552+
inclusion_upper_bounds.append(value)
553+
554+
value = data.get(ComponentMetricId.ACTIVE_POWER_INCLUSION_LOWER_BOUND)
555+
if value is not None:
556+
timestamp = max(data.timestamp, timestamp)
557+
inclusion_lower_bounds.append(value)
558+
559+
return (timestamp, inclusion_lower_bounds, inclusion_upper_bounds)
560+
561+
def _fetch_exclusion_bounds(
562+
self,
563+
battery_id: int,
564+
inverter_id: int,
565+
metrics_data: dict[int, ComponentMetricsData],
566+
) -> tuple[datetime, list[float], list[float]]:
567+
timestamp = _MIN_TIMESTAMP
568+
exclusion_lower_bounds: list[float] = []
569+
exclusion_upper_bounds: list[float] = []
570+
571+
# Exclusion upper and lower bounds are not related.
572+
# If one is missing, then we can still use the other.
573+
if battery_id in metrics_data:
574+
data = metrics_data[battery_id]
575+
value = data.get(ComponentMetricId.POWER_EXCLUSION_UPPER_BOUND)
576+
if value is not None:
577+
timestamp = max(timestamp, data.timestamp)
578+
exclusion_upper_bounds.append(value)
579+
580+
value = data.get(ComponentMetricId.POWER_EXCLUSION_LOWER_BOUND)
581+
if value is not None:
582+
timestamp = max(timestamp, data.timestamp)
583+
exclusion_lower_bounds.append(value)
584+
585+
if inverter_id in metrics_data:
586+
data = metrics_data[inverter_id]
587+
588+
value = data.get(ComponentMetricId.ACTIVE_POWER_EXCLUSION_UPPER_BOUND)
589+
if value is not None:
590+
timestamp = max(data.timestamp, timestamp)
591+
exclusion_upper_bounds.append(value)
592+
593+
value = data.get(ComponentMetricId.ACTIVE_POWER_EXCLUSION_LOWER_BOUND)
594+
if value is not None:
595+
timestamp = max(data.timestamp, timestamp)
596+
exclusion_lower_bounds.append(value)
597+
598+
return (timestamp, exclusion_lower_bounds, exclusion_upper_bounds)
599+
517600
def calculate(
518601
self,
519602
metrics_data: dict[int, ComponentMetricsData],
@@ -533,53 +616,45 @@ def calculate(
533616
High level metric calculated from the given metrics.
534617
Return None if there are no component metrics.
535618
"""
536-
# In the future we will have lower bound, too.
537-
538-
result = PowerMetrics(
539-
timestamp=_MIN_TIMESTAMP,
540-
supply_bound=Bound(0, 0),
541-
consume_bound=Bound(0, 0),
542-
)
619+
timestamp = _MIN_TIMESTAMP
620+
inclusion_bounds_lower = 0.0
621+
inclusion_bounds_upper = 0.0
622+
exclusion_bounds_lower = 0.0
623+
exclusion_bounds_upper = 0.0
543624

544625
for battery_id in working_batteries:
545-
supply_upper_bounds: list[float] = []
546-
consume_upper_bounds: list[float] = []
547-
548-
if battery_id in metrics_data:
549-
data = metrics_data[battery_id]
550-
551-
# Consume and supply bounds are not related.
552-
# If one is missing, then we can still use the other.
553-
value = data.get(ComponentMetricId.POWER_INCLUSION_UPPER_BOUND)
554-
if value is not None:
555-
result.timestamp = max(result.timestamp, data.timestamp)
556-
consume_upper_bounds.append(value)
557-
558-
value = data.get(ComponentMetricId.POWER_INCLUSION_LOWER_BOUND)
559-
if value is not None:
560-
result.timestamp = max(result.timestamp, data.timestamp)
561-
supply_upper_bounds.append(value)
562-
563626
inverter_id = self._bat_inv_map[battery_id]
564-
if inverter_id in metrics_data:
565-
data = metrics_data[inverter_id]
566-
567-
value = data.get(ComponentMetricId.ACTIVE_POWER_INCLUSION_UPPER_BOUND)
568-
if value is not None:
569-
result.timestamp = max(data.timestamp, result.timestamp)
570-
consume_upper_bounds.append(value)
571-
572-
value = data.get(ComponentMetricId.ACTIVE_POWER_INCLUSION_LOWER_BOUND)
573-
if value is not None:
574-
result.timestamp = max(data.timestamp, result.timestamp)
575-
supply_upper_bounds.append(value)
576-
577-
if len(consume_upper_bounds) > 0:
578-
result.consume_bound.upper += min(consume_upper_bounds)
579-
if len(supply_upper_bounds) > 0:
580-
result.supply_bound.lower += max(supply_upper_bounds)
627+
(
628+
_ts,
629+
inclusion_lower_bounds,
630+
inclusion_upper_bounds,
631+
) = self._fetch_inclusion_bounds(battery_id, inverter_id, metrics_data)
632+
timestamp = max(timestamp, _ts)
633+
(
634+
_ts,
635+
exclusion_lower_bounds,
636+
exclusion_upper_bounds,
637+
) = self._fetch_exclusion_bounds(battery_id, inverter_id, metrics_data)
638+
if len(inclusion_upper_bounds) > 0:
639+
inclusion_bounds_upper += min(inclusion_upper_bounds)
640+
if len(inclusion_lower_bounds) > 0:
641+
inclusion_bounds_lower += max(inclusion_lower_bounds)
642+
if len(exclusion_upper_bounds) > 0:
643+
exclusion_bounds_upper += max(exclusion_upper_bounds)
644+
if len(exclusion_lower_bounds) > 0:
645+
exclusion_bounds_lower += min(exclusion_lower_bounds)
581646

582-
if result.timestamp == _MIN_TIMESTAMP:
647+
if timestamp == _MIN_TIMESTAMP:
583648
return None
584649

585-
return result
650+
return PowerMetrics(
651+
timestamp=timestamp,
652+
inclusion_bounds=Bounds(
653+
Power.from_watts(inclusion_bounds_lower),
654+
Power.from_watts(inclusion_bounds_upper),
655+
),
656+
exclusion_bounds=Bounds(
657+
Power.from_watts(exclusion_bounds_lower),
658+
Power.from_watts(exclusion_bounds_upper),
659+
),
660+
)

src/frequenz/sdk/timeseries/battery_pool/_result_types.py

Lines changed: 23 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,17 @@
66
from dataclasses import dataclass, field
77
from datetime import datetime
88

9+
from .._quantities import Power
10+
911

1012
@dataclass
11-
class Bound:
13+
class Bounds:
1214
"""Lower and upper bound values."""
1315

14-
lower: float
16+
lower: Power
1517
"""Lower bound."""
1618

17-
upper: float
19+
upper: Power
1820
"""Upper bound."""
1921

2022

@@ -26,38 +28,24 @@ class PowerMetrics:
2628
timestamp: datetime = field(compare=False)
2729
"""Timestamp of the metrics."""
2830

29-
supply_bound: Bound
30-
"""Supply power bounds.
31-
32-
Upper bound is always 0 and will be supported later.
33-
Lower bound is negative number calculated with with the formula:
34-
```python
35-
working_pairs: Set[BatteryData, InverterData] # working batteries from the battery
36-
pool and adjacent inverters
37-
38-
supply_bound.lower = sum(
39-
max(
40-
battery.power_inclusion_lower_bound, inverter.active_power_inclusion_lower_bound)
41-
for each working battery in battery pool
42-
)
43-
)
44-
```
31+
# pylint: disable=line-too-long
32+
inclusion_bounds: Bounds
33+
"""Inclusion power bounds for all batteries in the battery pool instance.
34+
35+
This is the range within which power requests are allowed by the battery pool.
36+
37+
When exclusion bounds are present, they will exclude a subset of the inclusion
38+
bounds.
39+
40+
More details [here](https://github.com/frequenz-floss/frequenz-api-common/blob/v0.3.0/proto/frequenz/api/common/metrics.proto#L37-L91).
4541
"""
4642

47-
consume_bound: Bound
48-
"""Consume power bounds.
49-
50-
Lower bound is always 0 and will be supported later.
51-
Upper bound is positive number calculated with with the formula:
52-
```python
53-
working_pairs: Set[BatteryData, InverterData] # working batteries from the battery
54-
pool and adjacent inverters
55-
56-
consume_bound.upper = sum(
57-
min(
58-
battery.power_inclusion_upper_bound, inverter.active_power_inclusion_upper_bound)
59-
for each working battery in battery pool
60-
)
61-
)
62-
```
43+
exclusion_bounds: Bounds
44+
"""Exclusion power bounds for all batteries in the battery pool instance.
45+
46+
This is the range within which power requests are NOT allowed by the battery pool.
47+
If present, they will be a subset of the inclusion bounds.
48+
49+
More details [here](https://github.com/frequenz-floss/frequenz-api-common/blob/v0.3.0/proto/frequenz/api/common/metrics.proto#L37-L91).
6350
"""
51+
# pylint: enable=line-too-long

0 commit comments

Comments
 (0)