Skip to content

Commit 5a146d6

Browse files
authored
Expose only usable SoC and Capacity through the BatteryPool (#459)
With this PR, - soc values from the battery pool are always normalized to the 0-100% range, and they are now streamed as `Sample[Quantity]` objects. - only the usable capacity is streamed, eliminating the need for capacity bounds, so they are now streamed as `Sample[Energy]` objects. Closes #417
2 parents 165994a + 2afa1ba commit 5a146d6

File tree

5 files changed

+181
-173
lines changed

5 files changed

+181
-173
lines changed

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,11 @@
33

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

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

99
__all__ = [
1010
"BatteryPool",
1111
"PowerMetrics",
12-
"SoCMetrics",
13-
"CapacityMetrics",
1412
"Bound",
1513
]

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

Lines changed: 40 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@
1212

1313
from ...microgrid import connection_manager
1414
from ...microgrid.component import ComponentCategory, ComponentMetricId, InverterType
15+
from ...timeseries import Energy, Quantity, Sample
1516
from ._component_metrics import ComponentMetricsData
16-
from ._result_types import Bound, CapacityMetrics, PowerMetrics, SoCMetrics
17+
from ._result_types import Bound, PowerMetrics
1718

1819
_logger = logging.getLogger(__name__)
1920
_MIN_TIMESTAMP = datetime.min.replace(tzinfo=timezone.utc)
@@ -58,7 +59,7 @@ def battery_inverter_mapping(batteries: Iterable[int]) -> dict[int, int]:
5859

5960
# Formula output types class have no common interface
6061
# Print all possible types here.
61-
T = TypeVar("T", SoCMetrics, CapacityMetrics, PowerMetrics)
62+
T = TypeVar("T", Sample[Quantity], Sample[Energy], PowerMetrics)
6263

6364

6465
class MetricCalculator(ABC, Generic[T]):
@@ -141,7 +142,7 @@ def calculate(
141142
"""
142143

143144

144-
class CapacityCalculator(MetricCalculator[CapacityMetrics]):
145+
class CapacityCalculator(MetricCalculator[Sample[Energy]]):
145146
"""Define how to calculate Capacity metrics."""
146147

147148
def __init__(self, batteries: Set[int]) -> None:
@@ -189,7 +190,7 @@ def calculate(
189190
self,
190191
metrics_data: dict[int, ComponentMetricsData],
191192
working_batteries: set[int],
192-
) -> CapacityMetrics | None:
193+
) -> Sample[Energy] | None:
193194
"""Aggregate the metrics_data and calculate high level metric.
194195
195196
Missing components will be ignored. Formula will be calculated for all
@@ -206,9 +207,8 @@ def calculate(
206207
High level metric calculated from the given metrics.
207208
Return None if there are no component metrics.
208209
"""
209-
result = CapacityMetrics(
210-
timestamp=_MIN_TIMESTAMP, total_capacity=0, bound=Bound(lower=0, upper=0)
211-
)
210+
timestamp = _MIN_TIMESTAMP
211+
total_capacity = 0.0
212212

213213
for battery_id in working_batteries:
214214
if battery_id not in metrics_data:
@@ -223,16 +223,18 @@ def calculate(
223223
# All metrics are related so if any is missing then we skip the component.
224224
if capacity is None or soc_lower_bound is None or soc_upper_bound is None:
225225
continue
226+
usable_capacity = capacity * (soc_upper_bound - soc_lower_bound) / 100
227+
timestamp = max(timestamp, metrics.timestamp)
228+
total_capacity += usable_capacity
226229

227-
result.timestamp = max(result.timestamp, metrics.timestamp)
228-
result.total_capacity += capacity
229-
result.bound.upper += capacity * soc_upper_bound
230-
result.bound.lower += capacity * soc_lower_bound
231-
232-
return None if result.timestamp == _MIN_TIMESTAMP else result
230+
return (
231+
None
232+
if timestamp == _MIN_TIMESTAMP
233+
else Sample[Energy](timestamp, Energy.from_watt_hours(total_capacity))
234+
)
233235

234236

235-
class SoCCalculator(MetricCalculator[SoCMetrics]):
237+
class SoCCalculator(MetricCalculator[Sample[Quantity]]):
236238
"""Define how to calculate SoC metrics."""
237239

238240
def __init__(self, batteries: Set[int]) -> None:
@@ -281,7 +283,7 @@ def calculate(
281283
self,
282284
metrics_data: dict[int, ComponentMetricsData],
283285
working_batteries: set[int],
284-
) -> SoCMetrics | None:
286+
) -> Sample[Quantity] | None:
285287
"""Aggregate the metrics_data and calculate high level metric.
286288
287289
Missing components will be ignored. Formula will be calculated for all
@@ -299,9 +301,9 @@ def calculate(
299301
Return None if there are no component metrics.
300302
"""
301303
timestamp = _MIN_TIMESTAMP
302-
used_capacity: float = 0
303-
total_capacity: float = 0
304-
capacity_bound = Bound(0, 0)
304+
usable_capacity_x100: float = 0
305+
used_capacity_x100: float = 0
306+
total_capacity_x100: float = 0
305307

306308
for battery_id in working_batteries:
307309
if battery_id not in metrics_data:
@@ -323,29 +325,35 @@ def calculate(
323325
):
324326
continue
325327

328+
# The SoC bounds are in the 0-100 range, so to get the actual usable
329+
# capacity, we need to divide by 100.
330+
#
331+
# We only want to calculate the SoC, and the usable capacity calculation is
332+
# just an intermediate step, so don't have to divide by 100 here, because it
333+
# gets cancelled out later.
334+
#
335+
# Therefore, the variables are named with a `_x100` suffix.
336+
usable_capacity_x100 = capacity * (soc_upper_bound - soc_lower_bound)
337+
soc_scaled = (
338+
(soc - soc_lower_bound) / (soc_upper_bound - soc_lower_bound) * 100
339+
)
340+
soc_scaled = max(soc_scaled, 0)
326341
timestamp = max(timestamp, metrics.timestamp)
327-
used_capacity += capacity * soc
328-
total_capacity += capacity
329-
capacity_bound.upper += capacity * soc_upper_bound
330-
capacity_bound.lower += capacity * soc_lower_bound
342+
used_capacity_x100 += usable_capacity_x100 * soc_scaled
343+
total_capacity_x100 += usable_capacity_x100
331344

332345
if timestamp == _MIN_TIMESTAMP:
333346
return None
334347

335348
# To avoid zero division error
336-
if total_capacity == 0:
337-
return SoCMetrics(
349+
if total_capacity_x100 == 0:
350+
return Sample(
338351
timestamp=timestamp,
339-
average_soc=0,
340-
bound=Bound(0, 0),
352+
value=Quantity(0.0),
341353
)
342-
return SoCMetrics(
354+
return Sample(
343355
timestamp=timestamp,
344-
average_soc=used_capacity / total_capacity,
345-
bound=Bound(
346-
lower=capacity_bound.lower / total_capacity,
347-
upper=capacity_bound.upper / total_capacity,
348-
),
356+
value=Quantity(used_capacity_x100 / total_capacity_x100),
349357
)
350358

351359

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

Lines changed: 0 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -18,77 +18,6 @@ class Bound:
1818
"""Upper bound."""
1919

2020

21-
@dataclass
22-
class CapacityMetrics:
23-
"""Capacity metrics."""
24-
25-
# compare = False tells the dataclass to not use name for comparison methods
26-
timestamp: datetime = field(compare=False)
27-
"""Timestamp of the metrics,"""
28-
29-
total_capacity: float
30-
"""Total batteries capacity.
31-
32-
Calculated with the formula:
33-
```python
34-
working_batteries: Set[BatteryData] # working batteries from the battery pool
35-
total_capacity = sum(battery.capacity for battery in working_batteries)
36-
```
37-
"""
38-
bound: Bound
39-
"""Capacity bounds.
40-
41-
Bounds are calculated with the formula:
42-
```python
43-
working_batteries: Set[BatteryData] # working batteries from the battery
44-
bound.lower = sum(
45-
battery.capacity * battery.soc_lower_bound for battery in working_batteries)
46-
47-
bound.upper = sum(
48-
battery.capacity * battery.soc_upper_bound for battery in working_batteries)
49-
```
50-
"""
51-
52-
53-
@dataclass
54-
class SoCMetrics:
55-
"""Soc metrics."""
56-
57-
# compare = False tells the dataclass to not use name for comparison methods
58-
timestamp: datetime = field(compare=False)
59-
"""Timestamp of the metrics."""
60-
61-
average_soc: float
62-
"""Average soc.
63-
64-
Average soc is calculated with the formula:
65-
```python
66-
working_batteries: Set[BatteryData] # working batteries from the battery pool
67-
68-
used_capacity = sum(battery.capacity * battery.soc for battery in working_batteries)
69-
total_capacity = sum(battery.capacity for battery in working_batteries)
70-
average_soc = used_capacity/total_capacity
71-
```
72-
"""
73-
74-
bound: Bound
75-
"""SoC bounds weighted by capacity.
76-
77-
Bounds are calculated with the formula:
78-
capacity_lower_bound = sum(
79-
battery.capacity * battery.soc_lower_bound for battery in working_batteries)
80-
81-
capacity_upper_bound = sum(
82-
battery.capacity * battery.soc_upper_bound for battery in working_batteries)
83-
84-
total_capacity = sum(battery.capacity for battery in working_batteries)
85-
86-
bound.lower = capacity_lower_bound/total_capacity
87-
bound.upper = capacity_upper_bound/total_capacity
88-
89-
"""
90-
91-
9221
@dataclass
9322
class PowerMetrics:
9423
"""Power bounds metrics."""

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

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,17 @@
2020
from ...actor.power_distributing.result import Result
2121
from ...microgrid import connection_manager
2222
from ...microgrid.component import ComponentCategory
23+
from ...timeseries import Quantity, Sample
2324
from .._formula_engine import FormulaEngine, FormulaEnginePool
2425
from .._formula_engine._formula_generators import (
2526
BatteryPowerFormula,
2627
FormulaGeneratorConfig,
2728
FormulaType,
2829
)
29-
from .._quantities import Power
30+
from .._quantities import Energy, Power
3031
from ._methods import MetricAggregator, SendOnUpdate
3132
from ._metric_calculator import CapacityCalculator, PowerBoundsCalculator, SoCCalculator
32-
from ._result_types import CapacityMetrics, PowerMetrics, SoCMetrics
33+
from ._result_types import PowerMetrics
3334

3435

3536
class BatteryPool:
@@ -329,11 +330,29 @@ def consumption_power(self) -> FormulaEngine[Power]:
329330
return engine
330331

331332
@property
332-
def soc(self) -> MetricAggregator[SoCMetrics]:
333-
"""Get receiver to receive new soc metrics when they change.
333+
def soc(self) -> MetricAggregator[Sample[Quantity]]:
334+
"""Fetch the normalized average weighted-by-capacity SoC values for the pool.
334335
335-
Soc formulas are described in the receiver return type. None will be send if
336-
there is no component to calculate metric.
336+
The values are normalized to the 0-100% range.
337+
338+
Average soc is calculated with the formula:
339+
```
340+
working_batteries: Set[BatteryData] # working batteries from the battery pool
341+
342+
soc_scaled = max(
343+
0,
344+
(soc - soc_lower_bound) / (soc_upper_bound - soc_lower_bound) * 100,
345+
)
346+
used_capacity = sum(
347+
battery.usable_capacity * battery.soc_scaled
348+
for battery in working_batteries
349+
)
350+
total_capacity = sum(battery.usable_capacity for battery in working_batteries)
351+
average_soc = used_capacity/total_capacity
352+
```
353+
354+
`None` values will be sent if there are no components to calculate the metric
355+
with.
337356
338357
A receiver from the MetricAggregator can be obtained by calling the
339358
`new_receiver` method.
@@ -355,11 +374,19 @@ def soc(self) -> MetricAggregator[SoCMetrics]:
355374
return self._active_methods[method_name]
356375

357376
@property
358-
def capacity(self) -> MetricAggregator[CapacityMetrics]:
377+
def capacity(self) -> MetricAggregator[Sample[Energy]]:
359378
"""Get receiver to receive new capacity metrics when they change.
360379
361-
Capacity formulas are described in the receiver return type. None will be send
362-
if there is no component to calculate metrics.
380+
Calculated with the formula:
381+
```
382+
working_batteries: Set[BatteryData] # working batteries from the battery pool
383+
total_capacity = sum(
384+
battery.capacity * (soc_upper_bound - soc_lower_bound) / 100
385+
for battery in working_batteries
386+
)
387+
```
388+
389+
None will be send if there is no component to calculate metrics.
363390
364391
A receiver from the MetricAggregator can be obtained by calling the
365392
`new_receiver` method.

0 commit comments

Comments
 (0)