Skip to content

Commit a8a28cf

Browse files
committed
Normalize average SoC values from BatteryPool to 0-100% range
This allows us to hide actual battery bounds, and allows end users to not have to worry about SoC bounds, and just deal with normalized values instead. Signed-off-by: Sahas Subramanian <[email protected]>
1 parent 5c44da2 commit a8a28cf

File tree

4 files changed

+57
-57
lines changed

4 files changed

+57
-57
lines changed

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

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -299,9 +299,9 @@ def calculate(
299299
Return None if there are no component metrics.
300300
"""
301301
timestamp = _MIN_TIMESTAMP
302-
used_capacity: float = 0
303-
total_capacity: float = 0
304-
capacity_bound = Bound(0, 0)
302+
usable_capacity_x100: float = 0
303+
used_capacity_x100: float = 0
304+
total_capacity_x100: float = 0
305305

306306
for battery_id in working_batteries:
307307
if battery_id not in metrics_data:
@@ -323,29 +323,35 @@ def calculate(
323323
):
324324
continue
325325

326+
# The SoC bounds are in the 0-100 range, so to get the actual usable
327+
# capacity, we need to divide by 100.
328+
#
329+
# We only want to calculate the SoC, and the usable capacity calculation is
330+
# just an intermediate step, so don't have to divide by 100 here, because it
331+
# gets cancelled out later.
332+
#
333+
# Therefore, the variables are named with a `_x100` suffix.
334+
usable_capacity_x100 = capacity * (soc_upper_bound - soc_lower_bound)
335+
soc_scaled = (
336+
(soc - soc_lower_bound) / (soc_upper_bound - soc_lower_bound) * 100
337+
)
338+
soc_scaled = max(soc_scaled, 0)
326339
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
340+
used_capacity_x100 += usable_capacity_x100 * soc_scaled
341+
total_capacity_x100 += usable_capacity_x100
331342

332343
if timestamp == _MIN_TIMESTAMP:
333344
return None
334345

335346
# To avoid zero division error
336-
if total_capacity == 0:
347+
if total_capacity_x100 == 0:
337348
return SoCMetrics(
338349
timestamp=timestamp,
339350
average_soc=0,
340-
bound=Bound(0, 0),
341351
)
342352
return SoCMetrics(
343353
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-
),
354+
average_soc=used_capacity_x100 / total_capacity_x100,
349355
)
350356

351357

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

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -59,35 +59,27 @@ class SoCMetrics:
5959
"""Timestamp of the metrics."""
6060

6161
average_soc: float
62-
"""Average soc.
62+
"""Average SoC of working batteries in the pool, weighted by usable capacity.
63+
64+
The values are normalized to the 0-100% range.
6365
6466
Average soc is calculated with the formula:
6567
```python
6668
working_batteries: Set[BatteryData] # working batteries from the battery pool
6769
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+
battery.soc_scaled = max(
71+
0,
72+
(soc - soc_lower_bound) / (soc_upper_bound - soc_lower_bound) * 100,
73+
)
74+
used_capacity = sum(
75+
battery.usable_capacity * battery.soc_scaled
76+
for battery in working_batteries
77+
)
78+
total_capacity = sum(battery.usable_capacity for battery in working_batteries)
7079
average_soc = used_capacity/total_capacity
7180
```
7281
"""
7382

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

9284
@dataclass
9385
class PowerMetrics:

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -330,10 +330,11 @@ def consumption_power(self) -> FormulaEngine[Power]:
330330

331331
@property
332332
def soc(self) -> MetricAggregator[SoCMetrics]:
333-
"""Get receiver to receive new soc metrics when they change.
333+
"""Fetch the normalized average weighted-by-capacity SoC values for the pool.
334334
335-
Soc formulas are described in the receiver return type. None will be send if
336-
there is no component to calculate metric.
335+
The formulas for calculating this metric are described
336+
[here][frequenz.sdk.timeseries.battery_pool.SoCMetrics]. `None` values will be
337+
sent if there are no components to calculate the metric with.
337338
338339
A receiver from the MetricAggregator can be obtained by calling the
339340
`new_receiver` method.

tests/timeseries/_battery_pool/test_battery_pool.py

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -628,8 +628,8 @@ async def run_soc_test(setup_args: SetupArgs) -> None:
628628
timestamp=datetime.now(tz=timezone.utc),
629629
capacity=50,
630630
soc=30,
631-
soc_lower_bound=20,
632-
soc_upper_bound=80,
631+
soc_lower_bound=25,
632+
soc_upper_bound=75,
633633
),
634634
sampling_rate=0.05,
635635
)
@@ -643,8 +643,7 @@ async def run_soc_test(setup_args: SetupArgs) -> None:
643643
now = datetime.now(tz=timezone.utc)
644644
expected = SoCMetrics(
645645
timestamp=now,
646-
average_soc=30,
647-
bound=Bound(lower=20, upper=80),
646+
average_soc=10.0,
648647
)
649648
compare_messages(msg, expected, WAIT_FOR_COMPONENT_DATA_SEC + 0.2)
650649

@@ -653,18 +652,20 @@ async def run_soc_test(setup_args: SetupArgs) -> None:
653652
Scenario(
654653
batteries_in_pool[0],
655654
{"capacity": 150, "soc": 10},
656-
SoCMetrics(now, 15, Bound(20, 80)),
655+
SoCMetrics(now, 2.5),
657656
),
658657
Scenario(
659658
batteries_in_pool[0],
660-
{"soc_lower_bound": 0},
661-
SoCMetrics(now, 15, Bound(5, 80)),
659+
{
660+
"soc_lower_bound": 0.0,
661+
},
662+
SoCMetrics(now, 12.727272727272727),
662663
),
663664
# If NaN, then not include that battery in the metric.
664665
Scenario(
665666
batteries_in_pool[0],
666667
{"soc_upper_bound": float("NaN")},
667-
SoCMetrics(now, 30, Bound(20, 80)),
668+
SoCMetrics(now, 10),
668669
),
669670
# All batteries are sending NaN, can't calculate SoC so we should send None
670671
Scenario(
@@ -675,34 +676,34 @@ async def run_soc_test(setup_args: SetupArgs) -> None:
675676
Scenario(
676677
batteries_in_pool[1],
677678
{"soc": 30},
678-
SoCMetrics(now, 30, Bound(20, 80)),
679+
SoCMetrics(now, 10.0),
679680
),
680681
# Final metric didn't change, so nothing should be received.
681682
Scenario(
682683
batteries_in_pool[0],
683-
{"capacity": 0, "soc_lower_bound": 10, "soc_upper_bound": 100},
684+
{"capacity": 0, "soc_lower_bound": 10.0, "soc_upper_bound": 100.0},
684685
None,
685686
wait_for_result=False,
686687
),
687688
# Test zero division error
688689
Scenario(
689690
batteries_in_pool[1],
690691
{"capacity": 0},
691-
SoCMetrics(now, 0, Bound(0, 0)),
692+
SoCMetrics(now, 0),
692693
),
693694
Scenario(
694695
batteries_in_pool[0],
695-
{"capacity": 50},
696-
SoCMetrics(now, 10, Bound(10, 100)),
696+
{"capacity": 50, "soc": 55.0},
697+
SoCMetrics(now, 50.0),
697698
),
698699
Scenario(
699700
batteries_in_pool[1],
700-
{"capacity": 50},
701-
SoCMetrics(now, 20, Bound(15, 90)),
701+
{"capacity": 150},
702+
SoCMetrics(now, 25.0),
702703
),
703704
]
704705

705-
waiting_time_sec = setup_args.min_update_interval + 0.02
706+
waiting_time_sec = setup_args.min_update_interval + 0.2
706707
await run_scenarios(scenarios, streamer, receiver, waiting_time_sec)
707708

708709
await run_test_battery_status_channel(
@@ -711,15 +712,15 @@ async def run_soc_test(setup_args: SetupArgs) -> None:
711712
all_batteries=all_batteries,
712713
batteries_in_pool=batteries_in_pool,
713714
waiting_time_sec=waiting_time_sec,
714-
all_pool_result=SoCMetrics(now, 20, Bound(15, 90)),
715-
only_first_battery_result=SoCMetrics(now, 10, Bound(10, 100)),
715+
all_pool_result=SoCMetrics(now, 25.0),
716+
only_first_battery_result=SoCMetrics(now, 50.0),
716717
)
717718

718719
# One battery stopped sending data.
719720
await streamer.stop_streaming(batteries_in_pool[1])
720721
await asyncio.sleep(MAX_BATTERY_DATA_AGE_SEC + 0.2)
721722
msg = await asyncio.wait_for(receiver.receive(), timeout=waiting_time_sec)
722-
compare_messages(msg, SoCMetrics(now, 10, Bound(10, 100)), 0.2)
723+
compare_messages(msg, SoCMetrics(now, 50.0), 0.2)
723724

724725
# All batteries stopped sending data.
725726
await streamer.stop_streaming(batteries_in_pool[0])
@@ -731,7 +732,7 @@ async def run_soc_test(setup_args: SetupArgs) -> None:
731732
latest_data = streamer.get_current_component_data(batteries_in_pool[0])
732733
streamer.start_streaming(latest_data, sampling_rate=0.1)
733734
msg = await asyncio.wait_for(receiver.receive(), timeout=waiting_time_sec)
734-
compare_messages(msg, SoCMetrics(now, 10, Bound(10, 100)), 0.2)
735+
compare_messages(msg, SoCMetrics(now, 50.0), 0.2)
735736

736737

737738
async def run_power_bounds_test( # pylint: disable=too-many-locals

0 commit comments

Comments
 (0)