Skip to content

Commit ee58323

Browse files
authored
Support PV configurations with no or only some meters (#584)
- Put frequently used code in base function. - Properly handle PV power requests when no or only some meters exist. So far we only had configurations like this: Meter -> Inverter -> PV. However the scenario with Inverter -> PV is also possible and now handled correctly.
2 parents 922691a + dcf7b0d commit ee58323

File tree

10 files changed

+115
-119
lines changed

10 files changed

+115
-119
lines changed

RELEASE_NOTES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,7 @@
1515
## Bug Fixes
1616

1717
- Fixes a bug in the ring buffer updating the end timestamp of gaps when they are outdated.
18+
- Properly handles PV configurations with no or only some meters before the PV
19+
component.
20+
So far we only had configurations like this: Meter -> Inverter -> PV. However
21+
the scenario with Inverter -> PV is also possible and now handled correctly.

src/frequenz/sdk/timeseries/_formula_engine/_formula_generators/_consumer_power_formula.py

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,20 +37,9 @@ def generate(self) -> FormulaEngine[Power]:
3737
builder = self._get_builder(
3838
"consumer-power", ComponentMetricId.ACTIVE_POWER, Power.from_watts
3939
)
40-
component_graph = connection_manager.get().component_graph
41-
grid_component = next(
42-
(
43-
comp
44-
for comp in component_graph.components()
45-
if comp.category == ComponentCategory.GRID
46-
),
47-
None,
48-
)
4940

50-
if grid_component is None:
51-
raise ComponentNotFound("Grid component not found in the component graph.")
41+
grid_successors = self._get_grid_component_successors()
5242

53-
grid_successors = component_graph.successors(grid_component.component_id)
5443
if not grid_successors:
5544
raise ComponentNotFound("No components found in the component graph.")
5645

src/frequenz/sdk/timeseries/_formula_engine/_formula_generators/_formula_generator.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from frequenz.channels import Sender
1616

1717
from ....actor import ChannelRegistry, ComponentMetricRequest
18+
from ....microgrid import component, connection_manager
1819
from ....microgrid.component import ComponentMetricId
1920
from ..._quantities import QuantityT
2021
from .._formula_engine import FormulaEngine, FormulaEngine3Phase
@@ -100,6 +101,36 @@ def _get_builder(
100101
)
101102
return builder
102103

104+
def _get_grid_component_successors(self) -> set[component.Component]:
105+
"""Get the set of grid component successors in the component graph.
106+
107+
Returns:
108+
A set of grid component successors.
109+
110+
Raises:
111+
ComponentNotFound: If the grid component is not found in the component graph.
112+
ComponentNotFound: If no successor components are found in the component graph.
113+
"""
114+
component_graph = connection_manager.get().component_graph
115+
grid_component = next(
116+
iter(
117+
component_graph.components(
118+
component_category={component.ComponentCategory.GRID}
119+
)
120+
),
121+
None,
122+
)
123+
124+
if grid_component is None:
125+
raise ComponentNotFound("Grid component not found in the component graph.")
126+
127+
grid_successors = component_graph.successors(grid_component.component_id)
128+
129+
if not grid_successors:
130+
raise ComponentNotFound("No components found in the component graph.")
131+
132+
return grid_successors
133+
103134
@abstractmethod
104135
def generate(
105136
self,

src/frequenz/sdk/timeseries/_formula_engine/_formula_generators/_grid_current_formula.py

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,10 @@
55

66
from typing import Set
77

8-
from ....microgrid import connection_manager
98
from ....microgrid.component import Component, ComponentCategory, ComponentMetricId
109
from ..._quantities import Current
1110
from .._formula_engine import FormulaEngine, FormulaEngine3Phase
12-
from ._formula_generator import ComponentNotFound, FormulaGenerator
11+
from ._formula_generator import FormulaGenerator
1312

1413

1514
class GridCurrentFormula(FormulaGenerator[Current]):
@@ -24,22 +23,7 @@ def generate(self) -> FormulaEngine3Phase[Current]:
2423
Raises:
2524
ComponentNotFound: when the component graph doesn't have a `GRID` component.
2625
"""
27-
component_graph = connection_manager.get().component_graph
28-
grid_component = next(
29-
(
30-
comp
31-
for comp in component_graph.components()
32-
if comp.category == ComponentCategory.GRID
33-
),
34-
None,
35-
)
36-
37-
if grid_component is None:
38-
raise ComponentNotFound(
39-
"Unable to find a GRID component from the component graph."
40-
)
41-
42-
grid_successors = component_graph.successors(grid_component.component_id)
26+
grid_successors = self._get_grid_component_successors()
4327

4428
return FormulaEngine3Phase(
4529
"grid-current",

src/frequenz/sdk/timeseries/_formula_engine/_formula_generators/_grid_power_formula.py

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

44
"""Formula generator from component graph for Grid Power."""
55

6-
from ....microgrid import connection_manager
76
from ....microgrid.component import ComponentCategory, ComponentMetricId
87
from ..._quantities import Power
98
from .._formula_engine import FormulaEngine
10-
from ._formula_generator import ComponentNotFound, FormulaGenerator, FormulaType
9+
from ._formula_generator import FormulaGenerator, FormulaType
1110

1211

1312
class GridPowerFormula(FormulaGenerator[Power]):
@@ -27,22 +26,7 @@ def generate(
2726
builder = self._get_builder(
2827
"grid-power", ComponentMetricId.ACTIVE_POWER, Power.from_watts
2928
)
30-
component_graph = connection_manager.get().component_graph
31-
grid_component = next(
32-
(
33-
comp
34-
for comp in component_graph.components()
35-
if comp.category == ComponentCategory.GRID
36-
),
37-
None,
38-
)
39-
40-
if grid_component is None:
41-
raise ComponentNotFound(
42-
"Unable to find a GRID component from the component graph."
43-
)
44-
45-
grid_successors = component_graph.successors(grid_component.component_id)
29+
grid_successors = self._get_grid_component_successors()
4630

4731
# generate a formula that just adds values from all commponents that are
4832
# directly connected to the grid. If the requested formula type is

src/frequenz/sdk/timeseries/_formula_engine/_formula_generators/_producer_power_formula.py

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,7 @@
99
from ....microgrid.component import ComponentCategory, ComponentMetricId
1010
from ..._quantities import Power
1111
from .._formula_engine import FormulaEngine
12-
from ._formula_generator import (
13-
NON_EXISTING_COMPONENT_ID,
14-
ComponentNotFound,
15-
FormulaGenerator,
16-
)
12+
from ._formula_generator import NON_EXISTING_COMPONENT_ID, FormulaGenerator
1713

1814

1915
class ProducerPowerFormula(FormulaGenerator[Power]):
@@ -39,19 +35,7 @@ def generate(self) -> FormulaEngine[Power]:
3935
"producer_power", ComponentMetricId.ACTIVE_POWER, Power.from_watts
4036
)
4137
component_graph = connection_manager.get().component_graph
42-
grid_component = next(
43-
iter(
44-
component_graph.components(component_category={ComponentCategory.GRID})
45-
),
46-
None,
47-
)
48-
49-
if grid_component is None:
50-
raise ComponentNotFound("Grid component not found in the component graph.")
51-
52-
grid_successors = component_graph.successors(grid_component.component_id)
53-
if not grid_successors:
54-
raise ComponentNotFound("No components found in the component graph.")
38+
grid_successors = self._get_grid_component_successors()
5539

5640
if len(grid_successors) == 1:
5741
grid_meter = next(iter(grid_successors))

src/frequenz/sdk/timeseries/_formula_engine/_formula_generators/_pv_power_formula.py

Lines changed: 28 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,10 @@
99
from collections import abc
1010

1111
from ....microgrid import connection_manager
12-
from ....microgrid.component import ComponentCategory, ComponentMetricId, InverterType
12+
from ....microgrid.component import ComponentCategory, ComponentMetricId
1313
from ..._quantities import Power
1414
from .._formula_engine import FormulaEngine
15-
from ._formula_generator import (
16-
NON_EXISTING_COMPONENT_ID,
17-
FormulaGenerationError,
18-
FormulaGenerator,
19-
FormulaType,
20-
)
15+
from ._formula_generator import NON_EXISTING_COMPONENT_ID, FormulaGenerator, FormulaType
2116

2217
_logger = logging.getLogger(__name__)
2318

@@ -38,8 +33,8 @@ def generate(self) -> FormulaEngine[Power]:
3833
"pv-power", ComponentMetricId.ACTIVE_POWER, Power.from_watts
3934
)
4035

41-
pv_meters = self._get_pv_meters()
42-
if not pv_meters:
36+
pv_components = self._get_pv_power_components()
37+
if not pv_components:
4338
_logger.warning(
4439
"Unable to find any PV inverters in the component graph. "
4540
"Subscribing to the resampling actor with a non-existing "
@@ -55,7 +50,7 @@ def generate(self) -> FormulaEngine[Power]:
5550

5651
builder.push_oper("(")
5752
builder.push_oper("(")
58-
for idx, comp_id in enumerate(pv_meters):
53+
for idx, comp_id in enumerate(pv_components):
5954
if idx > 0:
6055
builder.push_oper("+")
6156

@@ -71,39 +66,30 @@ def generate(self) -> FormulaEngine[Power]:
7166

7267
return builder.build()
7368

74-
def _get_pv_meters(self) -> abc.Set[int]:
75-
component_graph = connection_manager.get().component_graph
69+
def _get_pv_power_components(self) -> abc.Set[int]:
70+
"""Get the component ids of the PV inverters or meters in the component graph.
7671
77-
pv_inverters = list(
78-
comp
79-
for comp in component_graph.components()
80-
if comp.category == ComponentCategory.INVERTER
81-
and comp.type == InverterType.SOLAR
82-
)
83-
pv_meters: set[int] = set()
72+
Returns:
73+
A set of component ids of the PV inverters or meters in the component graph.
8474
85-
if not pv_inverters:
86-
return pv_meters
75+
Raises:
76+
RuntimeError: if the grid component has no PV inverters or meters as successors.
77+
"""
78+
component_graph = connection_manager.get().component_graph
79+
grid_successors = self._get_grid_component_successors()
8780

88-
for pv_inverter in pv_inverters:
89-
predecessors = component_graph.predecessors(pv_inverter.component_id)
90-
if len(predecessors) != 1:
91-
raise FormulaGenerationError(
92-
"Expected exactly one predecessor for PV inverter "
93-
f"{pv_inverter.component_id}, but found {len(predecessors)}."
81+
if len(grid_successors) == 1:
82+
successor = next(iter(grid_successors))
83+
if successor.category != ComponentCategory.METER:
84+
raise RuntimeError(
85+
"Only grid successor in the component graph is not a meter."
9486
)
95-
meter = next(iter(predecessors))
96-
if meter.category != ComponentCategory.METER:
97-
raise FormulaGenerationError(
98-
f"Expected predecessor of PV inverter {pv_inverter.component_id} "
99-
f"to be a meter, but found {meter.category}."
100-
)
101-
meter_successors = component_graph.successors(meter.component_id)
102-
if len(meter_successors) != 1:
103-
raise FormulaGenerationError(
104-
f"Expected exactly one successor for meter {meter.component_id}"
105-
f", connected to PV inverter {pv_inverter.component_id}"
106-
f", but found {len(meter_successors)}."
107-
)
108-
pv_meters.add(meter.component_id)
109-
return pv_meters
87+
grid_successors = component_graph.successors(successor.component_id)
88+
89+
pv_meters_or_inverters: set[int] = set()
90+
91+
for successor in grid_successors:
92+
if component_graph.is_pv_chain(successor):
93+
pv_meters_or_inverters.add(successor.component_id)
94+
95+
return pv_meters_or_inverters

tests/timeseries/mock_microgrid.py

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -295,26 +295,20 @@ def add_batteries(self, count: int, no_meter: bool = False) -> None:
295295
self._connections.add(Connection(meter_id, inv_id))
296296
self._connections.add(Connection(inv_id, bat_id))
297297

298-
def add_solar_inverters(self, count: int) -> None:
298+
def add_solar_inverters(self, count: int, no_meter: bool = False) -> None:
299299
"""Add pv inverters and connected pv meters to the microgrid.
300300
301301
Args:
302302
count: number of inverters to add to the microgrid.
303+
no_meter: if True, do not add a meter for each inverter.
303304
"""
304305
for _ in range(count):
305306
meter_id = self._id_increment * 10 + self.meter_id_suffix
306307
inv_id = self._id_increment * 10 + self.inverter_id_suffix
307308
self._id_increment += 1
308309

309-
self.meter_ids.append(meter_id)
310310
self.pv_inverter_ids.append(inv_id)
311311

312-
self._components.add(
313-
Component(
314-
meter_id,
315-
ComponentCategory.METER,
316-
)
317-
)
318312
self._components.add(
319313
Component(
320314
inv_id,
@@ -323,9 +317,20 @@ def add_solar_inverters(self, count: int) -> None:
323317
)
324318
)
325319
self._start_inverter_streaming(inv_id)
326-
self._start_meter_streaming(meter_id)
327-
self._connections.add(Connection(self._connect_to, meter_id))
328-
self._connections.add(Connection(meter_id, inv_id))
320+
321+
if no_meter:
322+
self._connections.add(Connection(self._connect_to, inv_id))
323+
else:
324+
self.meter_ids.append(meter_id)
325+
self._components.add(
326+
Component(
327+
meter_id,
328+
ComponentCategory.METER,
329+
)
330+
)
331+
self._start_meter_streaming(meter_id)
332+
self._connections.add(Connection(self._connect_to, meter_id))
333+
self._connections.add(Connection(meter_id, inv_id))
329334

330335
def add_ev_chargers(self, count: int) -> None:
331336
"""Add EV Chargers to the microgrid.

tests/timeseries/mock_resampler.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,13 @@ async def send_meter_power(self, values: list[float | None]) -> None:
145145
sample = Sample(self._next_ts, None if not value else Quantity(value))
146146
await chan.send(sample)
147147

148+
async def send_pv_inverter_power(self, values: list[float | None]) -> None:
149+
"""Send the given values as resampler output for PV Inverter power."""
150+
assert len(values) == len(self._pv_inverter_power_senders)
151+
for chan, value in zip(self._pv_inverter_power_senders, values):
152+
sample = Sample(self._next_ts, None if not value else Quantity(value))
153+
await chan.send(sample)
154+
148155
async def send_evc_power(self, values: list[float | None]) -> None:
149156
"""Send the given values as resampler output for EV Charger power."""
150157
assert len(values) == len(self._ev_power_senders)

tests/timeseries/test_logical_meter.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,28 @@ async def test_pv_power(self, mocker: MockerFixture) -> None:
191191
await pv_consumption_power_receiver.receive()
192192
).value == Power.from_watts(0.0)
193193

194+
async def test_pv_power_no_meter(self, mocker: MockerFixture) -> None:
195+
"""Test the pv power formula."""
196+
mockgrid = MockMicrogrid(grid_side_meter=False)
197+
mockgrid.add_solar_inverters(2, no_meter=True)
198+
await mockgrid.start(mocker)
199+
200+
logical_meter = microgrid.logical_meter()
201+
pv_power_receiver = logical_meter.pv_power.new_receiver()
202+
pv_production_power_receiver = logical_meter.pv_production_power.new_receiver()
203+
pv_consumption_power_receiver = (
204+
logical_meter.pv_consumption_power.new_receiver()
205+
)
206+
207+
await mockgrid.mock_resampler.send_pv_inverter_power([-1.0, -2.0])
208+
assert (await pv_power_receiver.receive()).value == Power.from_watts(-3.0)
209+
assert (await pv_production_power_receiver.receive()).value == Power.from_watts(
210+
3.0
211+
)
212+
assert (
213+
await pv_consumption_power_receiver.receive()
214+
).value == Power.from_watts(0.0)
215+
194216
async def test_consumer_power_grid_meter(self, mocker: MockerFixture) -> None:
195217
"""Test the consumer power formula with a grid meter."""
196218
mockgrid = MockMicrogrid(grid_side_meter=True)

0 commit comments

Comments
 (0)