Skip to content

Stream exclusion bounds from the battery pool #537

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Aug 8, 2023
2 changes: 2 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

- 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.

- The `BatteryPool.power_bounds` method now streams inclusion/exclusion bounds. The bounds are now represented by `Power` objects and not `float`s.

## New Features

<!-- Here goes the main new features and examples or instructions on how to use them -->
Expand Down
12 changes: 12 additions & 0 deletions src/frequenz/sdk/actor/_data_sourcing/microgrid_api_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ def get_channel_name(self) -> str:
ComponentMetricId.POWER_INCLUSION_LOWER_BOUND: lambda msg: (
msg.power_inclusion_lower_bound
),
ComponentMetricId.POWER_EXCLUSION_LOWER_BOUND: lambda msg: (
msg.power_exclusion_lower_bound
),
ComponentMetricId.POWER_EXCLUSION_UPPER_BOUND: lambda msg: (
msg.power_exclusion_upper_bound
),
ComponentMetricId.POWER_INCLUSION_UPPER_BOUND: lambda msg: (
msg.power_inclusion_upper_bound
),
Expand All @@ -96,6 +102,12 @@ def get_channel_name(self) -> str:
ComponentMetricId.ACTIVE_POWER_INCLUSION_LOWER_BOUND: lambda msg: (
msg.active_power_inclusion_lower_bound
),
ComponentMetricId.ACTIVE_POWER_EXCLUSION_LOWER_BOUND: lambda msg: (
msg.active_power_exclusion_lower_bound
),
ComponentMetricId.ACTIVE_POWER_EXCLUSION_UPPER_BOUND: lambda msg: (
msg.active_power_exclusion_upper_bound
),
ComponentMetricId.ACTIVE_POWER_INCLUSION_UPPER_BOUND: lambda msg: (
msg.active_power_inclusion_upper_bound
),
Expand Down
4 changes: 4 additions & 0 deletions src/frequenz/sdk/microgrid/component/_component.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,13 @@ class ComponentMetricId(Enum):
CAPACITY = "capacity"

POWER_INCLUSION_LOWER_BOUND = "power_inclusion_lower_bound"
POWER_EXCLUSION_LOWER_BOUND = "power_exclusion_lower_bound"
POWER_EXCLUSION_UPPER_BOUND = "power_exclusion_upper_bound"
POWER_INCLUSION_UPPER_BOUND = "power_inclusion_upper_bound"

ACTIVE_POWER_INCLUSION_LOWER_BOUND = "active_power_inclusion_lower_bound"
ACTIVE_POWER_EXCLUSION_LOWER_BOUND = "active_power_exclusion_lower_bound"
ACTIVE_POWER_EXCLUSION_UPPER_BOUND = "active_power_exclusion_upper_bound"
ACTIVE_POWER_INCLUSION_UPPER_BOUND = "active_power_inclusion_upper_bound"

TEMPERATURE = "temperature"
4 changes: 2 additions & 2 deletions src/frequenz/sdk/timeseries/battery_pool/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@

"""Manage a pool of batteries."""

from ._result_types import Bound, PowerMetrics
from ._result_types import Bounds, PowerMetrics
from .battery_pool import BatteryPool

__all__ = [
"BatteryPool",
"PowerMetrics",
"Bound",
"Bounds",
]
167 changes: 121 additions & 46 deletions src/frequenz/sdk/timeseries/battery_pool/_metric_calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@

from ...microgrid import connection_manager
from ...microgrid.component import ComponentCategory, ComponentMetricId, InverterType
from ...timeseries import Energy, Percentage, Sample, Temperature
from ...timeseries import Sample
from .._quantities import Energy, Percentage, Power, Temperature
from ._component_metrics import ComponentMetricsData
from ._result_types import Bound, PowerMetrics
from ._result_types import Bounds, PowerMetrics

_logger = logging.getLogger(__name__)
_MIN_TIMESTAMP = datetime.min.replace(tzinfo=timezone.utc)
Expand Down Expand Up @@ -479,11 +480,15 @@ def __init__(
super().__init__(used_batteries)
self._battery_metrics = [
ComponentMetricId.POWER_INCLUSION_LOWER_BOUND,
ComponentMetricId.POWER_EXCLUSION_LOWER_BOUND,
ComponentMetricId.POWER_EXCLUSION_UPPER_BOUND,
ComponentMetricId.POWER_INCLUSION_UPPER_BOUND,
]

self._inverter_metrics = [
ComponentMetricId.ACTIVE_POWER_INCLUSION_LOWER_BOUND,
ComponentMetricId.ACTIVE_POWER_EXCLUSION_LOWER_BOUND,
ComponentMetricId.ACTIVE_POWER_EXCLUSION_UPPER_BOUND,
ComponentMetricId.ACTIVE_POWER_INCLUSION_UPPER_BOUND,
]

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

def _fetch_inclusion_bounds(
self,
battery_id: int,
inverter_id: int,
metrics_data: dict[int, ComponentMetricsData],
) -> tuple[datetime, list[float], list[float]]:
timestamp = _MIN_TIMESTAMP
inclusion_lower_bounds: list[float] = []
inclusion_upper_bounds: list[float] = []

# Inclusion upper and lower bounds are not related.
# If one is missing, then we can still use the other.
if battery_id in metrics_data:
data = metrics_data[battery_id]
value = data.get(ComponentMetricId.POWER_INCLUSION_UPPER_BOUND)
if value is not None:
timestamp = max(timestamp, data.timestamp)
inclusion_upper_bounds.append(value)

value = data.get(ComponentMetricId.POWER_INCLUSION_LOWER_BOUND)
if value is not None:
timestamp = max(timestamp, data.timestamp)
inclusion_lower_bounds.append(value)

if inverter_id in metrics_data:
data = metrics_data[inverter_id]

value = data.get(ComponentMetricId.ACTIVE_POWER_INCLUSION_UPPER_BOUND)
if value is not None:
timestamp = max(data.timestamp, timestamp)
inclusion_upper_bounds.append(value)

value = data.get(ComponentMetricId.ACTIVE_POWER_INCLUSION_LOWER_BOUND)
if value is not None:
timestamp = max(data.timestamp, timestamp)
inclusion_lower_bounds.append(value)

return (timestamp, inclusion_lower_bounds, inclusion_upper_bounds)

def _fetch_exclusion_bounds(
self,
battery_id: int,
inverter_id: int,
metrics_data: dict[int, ComponentMetricsData],
) -> tuple[datetime, list[float], list[float]]:
timestamp = _MIN_TIMESTAMP
exclusion_lower_bounds: list[float] = []
exclusion_upper_bounds: list[float] = []

# Exclusion upper and lower bounds are not related.
# If one is missing, then we can still use the other.
if battery_id in metrics_data:
data = metrics_data[battery_id]
value = data.get(ComponentMetricId.POWER_EXCLUSION_UPPER_BOUND)
if value is not None:
timestamp = max(timestamp, data.timestamp)
exclusion_upper_bounds.append(value)

value = data.get(ComponentMetricId.POWER_EXCLUSION_LOWER_BOUND)
if value is not None:
timestamp = max(timestamp, data.timestamp)
exclusion_lower_bounds.append(value)

if inverter_id in metrics_data:
data = metrics_data[inverter_id]

value = data.get(ComponentMetricId.ACTIVE_POWER_EXCLUSION_UPPER_BOUND)
if value is not None:
timestamp = max(data.timestamp, timestamp)
exclusion_upper_bounds.append(value)

value = data.get(ComponentMetricId.ACTIVE_POWER_EXCLUSION_LOWER_BOUND)
if value is not None:
timestamp = max(data.timestamp, timestamp)
exclusion_lower_bounds.append(value)

return (timestamp, exclusion_lower_bounds, exclusion_upper_bounds)

def calculate(
self,
metrics_data: dict[int, ComponentMetricsData],
Expand All @@ -533,53 +616,45 @@ def calculate(
High level metric calculated from the given metrics.
Return None if there are no component metrics.
"""
# In the future we will have lower bound, too.

result = PowerMetrics(
timestamp=_MIN_TIMESTAMP,
supply_bound=Bound(0, 0),
consume_bound=Bound(0, 0),
)
timestamp = _MIN_TIMESTAMP
inclusion_bounds_lower = 0.0
inclusion_bounds_upper = 0.0
exclusion_bounds_lower = 0.0
exclusion_bounds_upper = 0.0

for battery_id in working_batteries:
supply_upper_bounds: list[float] = []
consume_upper_bounds: list[float] = []

if battery_id in metrics_data:
data = metrics_data[battery_id]

# Consume and supply bounds are not related.
# If one is missing, then we can still use the other.
value = data.get(ComponentMetricId.POWER_INCLUSION_UPPER_BOUND)
if value is not None:
result.timestamp = max(result.timestamp, data.timestamp)
consume_upper_bounds.append(value)

value = data.get(ComponentMetricId.POWER_INCLUSION_LOWER_BOUND)
if value is not None:
result.timestamp = max(result.timestamp, data.timestamp)
supply_upper_bounds.append(value)

inverter_id = self._bat_inv_map[battery_id]
if inverter_id in metrics_data:
data = metrics_data[inverter_id]

value = data.get(ComponentMetricId.ACTIVE_POWER_INCLUSION_UPPER_BOUND)
if value is not None:
result.timestamp = max(data.timestamp, result.timestamp)
consume_upper_bounds.append(value)

value = data.get(ComponentMetricId.ACTIVE_POWER_INCLUSION_LOWER_BOUND)
if value is not None:
result.timestamp = max(data.timestamp, result.timestamp)
supply_upper_bounds.append(value)

if len(consume_upper_bounds) > 0:
result.consume_bound.upper += min(consume_upper_bounds)
if len(supply_upper_bounds) > 0:
result.supply_bound.lower += max(supply_upper_bounds)
(
_ts,
inclusion_lower_bounds,
inclusion_upper_bounds,
) = self._fetch_inclusion_bounds(battery_id, inverter_id, metrics_data)
timestamp = max(timestamp, _ts)
(
_ts,
exclusion_lower_bounds,
exclusion_upper_bounds,
) = self._fetch_exclusion_bounds(battery_id, inverter_id, metrics_data)
if len(inclusion_upper_bounds) > 0:
inclusion_bounds_upper += min(inclusion_upper_bounds)
if len(inclusion_lower_bounds) > 0:
inclusion_bounds_lower += max(inclusion_lower_bounds)
if len(exclusion_upper_bounds) > 0:
exclusion_bounds_upper += max(exclusion_upper_bounds)
if len(exclusion_lower_bounds) > 0:
exclusion_bounds_lower += min(exclusion_lower_bounds)

if result.timestamp == _MIN_TIMESTAMP:
if timestamp == _MIN_TIMESTAMP:
return None

return result
return PowerMetrics(
timestamp=timestamp,
inclusion_bounds=Bounds(
Power.from_watts(inclusion_bounds_lower),
Power.from_watts(inclusion_bounds_upper),
),
exclusion_bounds=Bounds(
Power.from_watts(exclusion_bounds_lower),
Power.from_watts(exclusion_bounds_upper),
),
)
58 changes: 23 additions & 35 deletions src/frequenz/sdk/timeseries/battery_pool/_result_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@
from dataclasses import dataclass, field
from datetime import datetime

from .._quantities import Power


@dataclass
class Bound:
class Bounds:
"""Lower and upper bound values."""

lower: float
lower: Power
"""Lower bound."""

upper: float
upper: Power
"""Upper bound."""


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

supply_bound: Bound
"""Supply power bounds.

Upper bound is always 0 and will be supported later.
Lower bound is negative number calculated with with the formula:
```python
working_pairs: Set[BatteryData, InverterData] # working batteries from the battery
pool and adjacent inverters

supply_bound.lower = sum(
max(
battery.power_inclusion_lower_bound, inverter.active_power_inclusion_lower_bound)
for each working battery in battery pool
)
)
```
# pylint: disable=line-too-long
inclusion_bounds: Bounds
"""Inclusion power bounds for all batteries in the battery pool instance.

This is the range within which power requests are allowed by the battery pool.

When exclusion bounds are present, they will exclude a subset of the inclusion
bounds.

More details [here](https://github.com/frequenz-floss/frequenz-api-common/blob/v0.3.0/proto/frequenz/api/common/metrics.proto#L37-L91).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could now point to the generated docs: https://frequenz-floss.github.io/frequenz-api-common/next/python-reference/frequenz/api/common/metrics_pb2/#frequenz.api.common.metrics_pb2.Metric.system_exclusion_bounds

We could even just use python symbols for cross referencing if we add these object indexes:

diff --git a/mkdocs.yml b/mkdocs.yml
index e8ddb46..13fe0f1 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -105,7 +105,9 @@ plugins:
           import:
             # See https://mkdocstrings.github.io/python/usage/#import for details
             - https://docs.python.org/3/objects.inv
+            - https://frequenz-floss.github.io/frequenz-api-common/v0.3/objects.inv
             - https://frequenz-floss.github.io/frequenz-channels-python/v0.14/objects.inv
+            - https://frequenz-floss.github.io/frequenz-api-microgrid/v0.15/objects.inv
             - https://grpc.github.io/grpc/python/objects.inv
             - https://networkx.org/documentation/stable/objects.inv
             - https://numpy.org/doc/stable/objects.inv
Suggested change
More details [here](https://github.com/frequenz-floss/frequenz-api-common/blob/v0.3.0/proto/frequenz/api/common/metrics.proto#L37-L91).
See [`frequenz.api.common.metrics_pb2.Metric.system_exclusion_bounds`][] for more details.

But for this to work we need to initialize the microgrid API generated docs and we need to tag v0.3.1 in the common API so we can have a stable link to the docs (for now only the next version is provided), so we can update this in a followup PR.

(as a nice side-effect we can remove the # pylint: disable=line-too-long too, probably :D)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"""

consume_bound: Bound
"""Consume power bounds.

Lower bound is always 0 and will be supported later.
Upper bound is positive number calculated with with the formula:
```python
working_pairs: Set[BatteryData, InverterData] # working batteries from the battery
pool and adjacent inverters

consume_bound.upper = sum(
min(
battery.power_inclusion_upper_bound, inverter.active_power_inclusion_upper_bound)
for each working battery in battery pool
)
)
```
exclusion_bounds: Bounds
"""Exclusion power bounds for all batteries in the battery pool instance.

This is the range within which power requests are NOT allowed by the battery pool.
If present, they will be a subset of the inclusion bounds.

More details [here](https://github.com/frequenz-floss/frequenz-api-common/blob/v0.3.0/proto/frequenz/api/common/metrics.proto#L37-L91).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same.

"""
# pylint: enable=line-too-long
Loading