Skip to content

Commit de2457b

Browse files
committed
Add a formula generator for SoC in the LogicalMeter
This is temporarily part of the `LogicalMeter` and will be moved to the `BatteryPool` within the next few releases. Signed-off-by: Sahas Subramanian <[email protected]>
1 parent 1f42047 commit de2457b

File tree

4 files changed

+159
-0
lines changed

4 files changed

+159
-0
lines changed

src/frequenz/sdk/timeseries/logical_meter/_formula_generators/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"""Generators for formulas from component graphs."""
55

66
from ._battery_power_formula import BatteryPowerFormula
7+
from ._battery_soc_formula import BatterySoCFormula
78
from ._formula_generator import (
89
ComponentNotFound,
910
FormulaGenerationError,
@@ -22,6 +23,7 @@
2223
#
2324
"GridPowerFormula",
2425
"BatteryPowerFormula",
26+
"BatterySoCFormula",
2527
"PVPowerFormula",
2628
#
2729
# Exceptions
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# License: MIT
2+
# Copyright © 2022 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Formula generator from component graph for Grid Power."""
5+
6+
import asyncio
7+
8+
from frequenz.channels import Receiver
9+
10+
from .....sdk import microgrid
11+
from ....microgrid.component import ComponentCategory, ComponentMetricId, InverterType
12+
from ... import Sample
13+
from .._formula_engine import FormulaEngine
14+
from ._formula_generator import (
15+
ComponentNotFound,
16+
FormulaGenerationError,
17+
FormulaGenerator,
18+
)
19+
20+
21+
class _ActiveBatteryReceiver(Receiver[Sample]):
22+
"""Returns a Sample from a battery, only if the attached inverter is active."""
23+
24+
def __init__(self, inv_recv: Receiver[Sample], bat_recv: Receiver[Sample]):
25+
self._inv_recv = inv_recv
26+
self._bat_recv = bat_recv
27+
28+
async def ready(self) -> None:
29+
"""Wait until the next Sample is ready."""
30+
await asyncio.gather(self._inv_recv.ready(), self._bat_recv.ready())
31+
32+
def consume(self) -> Sample:
33+
"""Return the next Sample.
34+
35+
Returns:
36+
the next Sample.
37+
"""
38+
inv = self._inv_recv.consume()
39+
bat = self._bat_recv.consume()
40+
if inv.value is None:
41+
return inv
42+
return bat
43+
44+
45+
class BatterySoCFormula(FormulaGenerator):
46+
"""Creates a formula engine from the component graph for calculating battery soc."""
47+
48+
async def generate(
49+
self,
50+
) -> FormulaEngine:
51+
"""Make a formula for the average battery soc of a microgrid.
52+
53+
If there's no data coming from an inverter or a battery, the corresponding
54+
battery will be excluded from the calculation.
55+
56+
Returns:
57+
A formula engine that will calculate average battery soc values.
58+
59+
Raises:
60+
ComponentNotFound: if there are no batteries in the component graph, or if
61+
they don't have an inverter as a predecessor.
62+
FormulaGenerationError: If a battery has a non-inverter predecessor
63+
in the component graph.
64+
"""
65+
builder = self._get_builder(ComponentMetricId.ACTIVE_POWER)
66+
component_graph = microgrid.get().component_graph
67+
inv_bat_pairs = {
68+
comp: component_graph.successors(comp.component_id)
69+
for comp in component_graph.components()
70+
if comp.category == ComponentCategory.INVERTER
71+
and comp.type == InverterType.BATTERY
72+
}
73+
74+
if not inv_bat_pairs:
75+
raise ComponentNotFound(
76+
"Unable to find any battery inverters in the component graph."
77+
)
78+
79+
soc_streams = []
80+
for inv, bats in inv_bat_pairs.items():
81+
bat = list(bats)[0]
82+
if len(bats) != 1:
83+
raise FormulaGenerationError(
84+
f"Expected exactly one battery for inverter {inv}, got {bats}"
85+
)
86+
87+
# pylint: disable=protected-access
88+
soc_recv = _ActiveBatteryReceiver(
89+
await builder._get_resampled_receiver(
90+
inv.component_id, ComponentMetricId.ACTIVE_POWER
91+
),
92+
await builder._get_resampled_receiver(
93+
bat.component_id, ComponentMetricId.SOC
94+
),
95+
)
96+
# pylint: enable=protected-access
97+
98+
soc_streams.append((f"{bat.component_id}", soc_recv, False))
99+
100+
builder.push_average(soc_streams)
101+
102+
return builder.build()

src/frequenz/sdk/timeseries/logical_meter/_logical_meter.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from ._formula_engine import FormulaEngine
2020
from ._formula_generators import (
2121
BatteryPowerFormula,
22+
BatterySoCFormula,
2223
FormulaGenerator,
2324
GridPowerFormula,
2425
PVPowerFormula,
@@ -187,3 +188,14 @@ async def pv_power(self) -> Receiver[Sample]:
187188
A *new* receiver that will stream PV power production values.
188189
"""
189190
return await self._get_formula_stream("pv_power", PVPowerFormula)
191+
192+
async def _soc(self) -> Receiver[Sample]:
193+
"""Fetch the SoC of the active batteries in the microgrid.
194+
195+
NOTE: This method is part of the logical meter only temporarily, and will get
196+
moved to the `BatteryPool` within the next few releases.
197+
198+
Returns:
199+
A *new* receiver that will stream average SoC of active batteries.
200+
"""
201+
return await self._get_formula_stream("soc", BatterySoCFormula)

tests/timeseries/test_logical_meter.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,3 +204,46 @@ async def test_battery_and_pv_power( # pylint: disable=too-many-locals
204204
assert battery_results == battery_inv_sums
205205
assert len(pv_results) == 10
206206
assert pv_results == pv_inv_sums
207+
208+
async def test_soc(self, mocker: MockerFixture) -> None:
209+
"""Test the soc calculation."""
210+
mockgrid = await MockMicrogrid.new(mocker)
211+
mockgrid.add_solar_inverters(2)
212+
mockgrid._id_increment = 8 # pylint: disable=protected-access
213+
mockgrid.add_batteries(3)
214+
request_sender, channel_registry = await mockgrid.start()
215+
logical_meter = LogicalMeter(
216+
channel_registry,
217+
request_sender,
218+
microgrid.get().component_graph,
219+
)
220+
221+
soc_recv = await logical_meter._soc() # pylint: disable=protected-access
222+
223+
bat_receivers = [
224+
await self._get_resampled_stream(
225+
logical_meter,
226+
channel_registry,
227+
request_sender,
228+
bat_id,
229+
ComponentMetricId.SOC,
230+
)
231+
for bat_id in mockgrid.battery_ids
232+
]
233+
234+
for ctr in range(10):
235+
bat_vals = []
236+
for recv in bat_receivers:
237+
val = await recv.receive()
238+
assert val is not None and val.value is not None
239+
bat_vals.append(val.value)
240+
241+
assert len(bat_vals) == 3
242+
# After 7 values, the inverter with component_id > 100 stops sending
243+
# data. And the values from the last battery goes out of the calculation.
244+
# So we drop it from out control value as well.
245+
if ctr >= 7:
246+
bat_vals = bat_vals[:2]
247+
assert (await soc_recv.receive()).value == sum(bat_vals) / len(bat_vals)
248+
249+
await mockgrid.cleanup()

0 commit comments

Comments
 (0)