Skip to content

Commit 3906299

Browse files
Fetch and stream 3-phase voltage (#815)
Fetch and stream the phase-to-neutral voltage from a source component. Each phase of the phase-to-neutral voltage is fetched from the source component individually from the resampling actor and then streamed as a 3-phase sample. It was agreed to not use the formula engine to get voltage metrics as voltage does not need formula composition. Also the phase-to-phase 3-phase voltage is not implemented as in the future the microgrid API will expose that data. The user can still perform a simple calculation to get phase-to-phase voltage given phase-to-neutral voltage. Fixes #783
2 parents 8ad2249 + a4a8985 commit 3906299

File tree

14 files changed

+591
-76
lines changed

14 files changed

+591
-76
lines changed

RELEASE_NOTES.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,13 @@
4646

4747
- The `ComponentGraph.components()` parameters `component_id` and `component_category` were renamed to `component_ids` and `component_categories`, respectively.
4848

49-
- The `GridFrequency.component` propery was renamed to `GridFrequency.source`
49+
- The `GridFrequency.component` property was renamed to `GridFrequency.source`
5050

5151
- The `microgrid.frequency()` method no longer supports passing the `component` parameter. Instead the best component is automatically selected.
5252

5353
## New Features
5454

55-
<!-- Here goes the main new features and examples or instructions on how to use them -->
55+
- A new method `microgrid.voltage()` was added to allow easy access to the phase-to-neutral 3-phase voltage of the microgrid.
5656

5757
## Bug Fixes
5858

src/frequenz/sdk/actor/_data_sourcing/microgrid_api_source.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@
7474
ComponentMetricId.CURRENT_PHASE_1: lambda msg: msg.current_per_phase[0],
7575
ComponentMetricId.CURRENT_PHASE_2: lambda msg: msg.current_per_phase[1],
7676
ComponentMetricId.CURRENT_PHASE_3: lambda msg: msg.current_per_phase[2],
77+
ComponentMetricId.VOLTAGE_PHASE_1: lambda msg: msg.voltage_per_phase[0],
78+
ComponentMetricId.VOLTAGE_PHASE_2: lambda msg: msg.voltage_per_phase[1],
79+
ComponentMetricId.VOLTAGE_PHASE_3: lambda msg: msg.voltage_per_phase[2],
7780
ComponentMetricId.FREQUENCY: lambda msg: msg.frequency,
7881
}
7982

src/frequenz/sdk/microgrid/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@
130130
frequency,
131131
grid,
132132
logical_meter,
133+
voltage,
133134
)
134135

135136

@@ -155,4 +156,5 @@ async def initialize(host: str, port: int, resampler_config: ResamplerConfig) ->
155156
"frequency",
156157
"logical_meter",
157158
"metadata",
159+
"voltage",
158160
]

src/frequenz/sdk/microgrid/_data_pipeline.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from ..actor._actor import Actor
2222
from ..timeseries._base_types import PoolType
2323
from ..timeseries._grid_frequency import GridFrequency
24+
from ..timeseries._voltage_streamer import VoltageStreamer
2425
from ..timeseries.grid import Grid
2526
from ..timeseries.grid import get as get_grid
2627
from ..timeseries.grid import initialize as initialize_grid
@@ -119,6 +120,7 @@ def __init__(
119120
self._ev_charger_pools: dict[frozenset[int], EVChargerPool] = {}
120121
self._battery_pools: dict[frozenset[int], BatteryPoolReferenceStore] = {}
121122
self._frequency_instance: GridFrequency | None = None
123+
self._voltage_instance: VoltageStreamer | None = None
122124

123125
def frequency(self) -> GridFrequency:
124126
"""Fetch the grid frequency for the microgrid.
@@ -134,6 +136,20 @@ def frequency(self) -> GridFrequency:
134136

135137
return self._frequency_instance
136138

139+
def voltage(self) -> VoltageStreamer:
140+
"""Fetch the 3-phase voltage for the microgrid.
141+
142+
Returns:
143+
The VoltageStreamer instance.
144+
"""
145+
if not self._voltage_instance:
146+
self._voltage_instance = VoltageStreamer(
147+
self._resampling_request_sender(),
148+
self._channel_registry,
149+
)
150+
151+
return self._voltage_instance
152+
137153
def logical_meter(self) -> LogicalMeter:
138154
"""Return the logical meter instance.
139155
@@ -409,6 +425,15 @@ def frequency() -> GridFrequency:
409425
return _get().frequency()
410426

411427

428+
def voltage() -> VoltageStreamer:
429+
"""Return the 3-phase voltage for the microgrid.
430+
431+
Returns:
432+
The 3-phase voltage.
433+
"""
434+
return _get().voltage()
435+
436+
412437
def logical_meter() -> LogicalMeter:
413438
"""Return the logical meter instance.
414439

src/frequenz/sdk/microgrid/component/_component_data.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,11 @@ class InverterData(ComponentData):
237237
-ve current means supply into the grid.
238238
"""
239239

240+
voltage_per_phase: tuple[float, float, float]
241+
"""The AC voltage in Volts (V) between the line and the neutral wire for
242+
phase/line 1, 2 and 3 respectively.
243+
"""
244+
240245
# pylint: disable=line-too-long
241246
active_power_inclusion_lower_bound: float
242247
"""Lower inclusion bound for inverter power in watts.
@@ -312,6 +317,11 @@ def from_proto(cls, raw: microgrid_pb.ComponentData) -> InverterData:
312317
raw.inverter.data.ac.phase_2.current.value,
313318
raw.inverter.data.ac.phase_3.current.value,
314319
),
320+
voltage_per_phase=(
321+
raw.inverter.data.ac.phase_1.voltage.value,
322+
raw.inverter.data.ac.phase_2.voltage.value,
323+
raw.inverter.data.ac.phase_3.voltage.value,
324+
),
315325
active_power_inclusion_lower_bound=raw_power.system_inclusion_bounds.lower,
316326
active_power_exclusion_lower_bound=raw_power.system_exclusion_bounds.lower,
317327
active_power_inclusion_upper_bound=raw_power.system_inclusion_bounds.upper,

src/frequenz/sdk/microgrid/component_graph.py

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@
3434

3535
_logger = logging.getLogger(__name__)
3636

37+
# pylint: disable=too-many-lines
38+
3739

3840
class InvalidGraphError(Exception):
3941
"""Exception type that will be thrown if graph data is not valid."""
@@ -289,8 +291,40 @@ def dfs(
289291
the condition function.
290292
"""
291293

294+
@abstractmethod
295+
def find_first_descendant_component(
296+
self,
297+
*,
298+
root_category: ComponentCategory,
299+
descendant_categories: Iterable[ComponentCategory],
300+
) -> Component:
301+
"""Find the first descendant component given root and descendant categories.
302+
303+
This method searches for the root component within the provided root
304+
category. If multiple components share the same root category, the
305+
first found one is considered as the root component.
306+
307+
Subsequently, it looks for the first descendant component from the root
308+
component, considering only the immediate descendants.
292309
293-
class _MicrogridComponentGraph(ComponentGraph):
310+
The priority of the component to search for is determined by the order
311+
of the descendant categories, with the first category having the
312+
highest priority.
313+
314+
Args:
315+
root_category: The category of the root component to search for.
316+
descendant_categories: The descendant categories to search for the
317+
first descendant component in.
318+
319+
Returns:
320+
The first descendant component found in the component graph,
321+
considering the specified root and descendant categories.
322+
"""
323+
324+
325+
class _MicrogridComponentGraph(
326+
ComponentGraph
327+
): # pylint: disable=too-many-public-methods
294328
"""ComponentGraph implementation designed to work with the microgrid API.
295329
296330
For internal-only use of the `microgrid` package.
@@ -748,6 +782,67 @@ def dfs(
748782

749783
return component
750784

785+
def find_first_descendant_component(
786+
self,
787+
*,
788+
root_category: ComponentCategory,
789+
descendant_categories: Iterable[ComponentCategory],
790+
) -> Component:
791+
"""Find the first descendant component given root and descendant categories.
792+
793+
This method searches for the root component within the provided root
794+
category. If multiple components share the same root category, the
795+
first found one is considered as the root component.
796+
797+
Subsequently, it looks for the first descendant component from the root
798+
component, considering only the immediate descendants.
799+
800+
The priority of the component to search for is determined by the order
801+
of the descendant categories, with the first category having the
802+
highest priority.
803+
804+
Args:
805+
root_category: The category of the root component to search for.
806+
descendant_categories: The descendant categories to search for the
807+
first descendant component in.
808+
809+
Raises:
810+
ValueError: when the root component is not found in the component
811+
graph or when no component is found in the given categories.
812+
813+
Returns:
814+
The first descendant component found in the component graph,
815+
considering the specified root and descendant categories.
816+
"""
817+
root_component = next(
818+
(comp for comp in self.components(component_categories={root_category})),
819+
None,
820+
)
821+
822+
if root_component is None:
823+
raise ValueError(f"Root component not found for {root_category.name}")
824+
825+
# Sort by component ID to ensure consistent results.
826+
successors = sorted(
827+
self.successors(root_component.component_id),
828+
key=lambda comp: comp.component_id,
829+
)
830+
831+
def find_component(component_category: ComponentCategory) -> Component | None:
832+
return next(
833+
(comp for comp in successors if comp.category == component_category),
834+
None,
835+
)
836+
837+
# Find the first component that matches the given descendant categories
838+
# in the order of the categories list.
839+
component = next(filter(None, map(find_component, descendant_categories)), None)
840+
841+
if component is None:
842+
raise ValueError("Component not found in any of the descendant categories.")
843+
844+
return component
845+
751846
def _validate_graph(self) -> None:
752847
"""Check that the underlying graph data is valid.
753848

src/frequenz/sdk/timeseries/_grid_frequency.py

Lines changed: 12 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,20 @@ def __init__(
5858
channel_registry: The channel registry to use for the grid frequency.
5959
source: The source component to use to receive the grid frequency.
6060
"""
61+
if not source:
62+
component_graph = connection_manager.get().component_graph
63+
source = component_graph.find_first_descendant_component(
64+
root_category=ComponentCategory.GRID,
65+
descendant_categories=(
66+
ComponentCategory.METER,
67+
ComponentCategory.INVERTER,
68+
ComponentCategory.EV_CHARGER,
69+
),
70+
)
71+
6172
self._request_sender = data_sourcing_request_sender
6273
self._channel_registry = channel_registry
63-
self._source_component = source or GridFrequency.find_frequency_source()
74+
self._source_component = source
6475
self._component_metric_request = create_request(
6576
self._source_component.component_id
6677
)
@@ -103,71 +114,3 @@ async def _send_request(self) -> None:
103114
"""Send the request for grid frequency."""
104115
await self._request_sender.send(self._component_metric_request)
105116
_logger.debug("Sent request for grid frequency: %s", self._source_component)
106-
107-
@staticmethod
108-
def find_frequency_source() -> Component:
109-
"""Find the source component that will be used for grid frequency.
110-
111-
Will use the first meter it can find to gather the frequency.
112-
If no meter is available, the first inverter will be used and finally the first EV charger.
113-
114-
Returns:
115-
The component that will be used for grid frequency.
116-
117-
Raises:
118-
ValueError: when the component graph doesn't have a `GRID` component.
119-
"""
120-
component_graph = connection_manager.get().component_graph
121-
grid_component = next(
122-
(
123-
comp
124-
for comp in component_graph.components()
125-
if comp.category == ComponentCategory.GRID
126-
),
127-
None,
128-
)
129-
130-
if grid_component is None:
131-
raise ValueError(
132-
"Unable to find a GRID component from the component graph."
133-
)
134-
135-
# Sort by component id to ensure consistent results
136-
grid_successors = sorted(
137-
component_graph.successors(grid_component.component_id),
138-
key=lambda comp: comp.component_id,
139-
)
140-
141-
def find_component(component_category: ComponentCategory) -> Component | None:
142-
return next(
143-
(
144-
comp
145-
for comp in grid_successors
146-
if comp.category == component_category
147-
),
148-
None,
149-
)
150-
151-
# Find the first component that is either a meter, inverter or EV charger
152-
# with category priority in that order.
153-
component = next(
154-
filter(
155-
None,
156-
map(
157-
find_component,
158-
[
159-
ComponentCategory.METER,
160-
ComponentCategory.INVERTER,
161-
ComponentCategory.EV_CHARGER,
162-
],
163-
),
164-
),
165-
None,
166-
)
167-
168-
if component is None:
169-
raise ValueError(
170-
"Unable to find a METER, INVERTER or EV_CHARGER component from the component graph."
171-
)
172-
173-
return component

0 commit comments

Comments
 (0)