diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 91d3f15d3..60629eefa 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -102,6 +102,13 @@ This release replaces the `@actor` decorator with a new `Actor` class. - `Actor`: This new class inherits from `BackgroundService` and it replaces the `@actor` decorator. +- Calling `microgrid.initialize()` now also initializes the microgrid's grid connection point as a singleton object of a newly added type `Grid`. This object can be obtained by calling `microgrid.grid.get()`. This object exposes the max current that can course through the grid connection point, which is useful for the power distribution algorithm. The max current is provided by the Microgrid API, and can be obtained by calling `microgrid.grid.get().fuse.max_current`. + + Note that a microgrid is allowed to have zero or one grid connection point. Microgrids configured as islands will have zero grid connection points, and microgrids configured as grid-connected will have one grid connection point. + +- A new class `Fuse` has been added to represent fuses. This class has a member variable `max_current` which represents the maximum current that can course through the fuse. If the current flowing through a fuse is greater than this limit, then the fuse will break the circuit. + + ## Bug Fixes - Fixes a bug in the ring buffer updating the end timestamp of gaps when they are outdated. diff --git a/src/frequenz/sdk/microgrid/__init__.py b/src/frequenz/sdk/microgrid/__init__.py index ad303f0d3..cb70e3e2e 100644 --- a/src/frequenz/sdk/microgrid/__init__.py +++ b/src/frequenz/sdk/microgrid/__init__.py @@ -8,7 +8,7 @@ """ from ..actor import ResamplerConfig -from . import _data_pipeline, client, component, connection_manager +from . import _data_pipeline, client, component, connection_manager, fuse, grid from ._data_pipeline import battery_pool, ev_charger_pool, logical_meter from ._graph import ComponentGraph @@ -22,6 +22,11 @@ async def initialize(host: str, port: int, resampler_config: ResamplerConfig) -> resampler_config: Configuration for the resampling actor. """ await connection_manager.initialize(host, port) + + api_client = connection_manager.get().api_client + components = await api_client.components() + grid.initialize(components) + await _data_pipeline.initialize(resampler_config) @@ -32,5 +37,7 @@ async def initialize(host: str, port: int, resampler_config: ResamplerConfig) -> "component", "battery_pool", "ev_charger_pool", + "fuse", + "grid", "logical_meter", ] diff --git a/src/frequenz/sdk/microgrid/client/_client.py b/src/frequenz/sdk/microgrid/client/_client.py index e581781b4..534efcf43 100644 --- a/src/frequenz/sdk/microgrid/client/_client.py +++ b/src/frequenz/sdk/microgrid/client/_client.py @@ -36,6 +36,7 @@ ) from ..component._component import ( _component_category_from_protobuf, + _component_metadata_from_protobuf, _component_type_from_protobuf, ) from ._connection import Connection @@ -254,6 +255,7 @@ async def components(self) -> Iterable[Component]: c.id, _component_category_from_protobuf(c.category), _component_type_from_protobuf(c.category, c.inverter), + _component_metadata_from_protobuf(c.category, c.grid), ), components_only, ) diff --git a/src/frequenz/sdk/microgrid/component/__init__.py b/src/frequenz/sdk/microgrid/component/__init__.py index 9e6d74606..f1c58851d 100644 --- a/src/frequenz/sdk/microgrid/component/__init__.py +++ b/src/frequenz/sdk/microgrid/component/__init__.py @@ -6,7 +6,14 @@ This package provides classes to operate con microgrid components. """ -from ._component import Component, ComponentCategory, ComponentMetricId, InverterType +from ._component import ( + Component, + ComponentCategory, + ComponentMetadata, + ComponentMetricId, + GridMetadata, + InverterType, +) from ._component_data import ( BatteryData, ComponentData, @@ -21,10 +28,12 @@ "Component", "ComponentData", "ComponentCategory", + "ComponentMetadata", "ComponentMetricId", "EVChargerCableState", "EVChargerComponentState", "EVChargerData", + "GridMetadata", "InverterData", "InverterType", "MeterData", diff --git a/src/frequenz/sdk/microgrid/component/_component.py b/src/frequenz/sdk/microgrid/component/_component.py index 3a3c24673..16f4fa936 100644 --- a/src/frequenz/sdk/microgrid/component/_component.py +++ b/src/frequenz/sdk/microgrid/component/_component.py @@ -7,11 +7,14 @@ from dataclasses import dataclass from enum import Enum -from typing import Optional import frequenz.api.common.components_pb2 as components_pb +import frequenz.api.microgrid.grid_pb2 as grid_pb import frequenz.api.microgrid.inverter_pb2 as inverter_pb +from ...timeseries import Current +from ..fuse import Fuse + class ComponentType(Enum): """A base class from which individual component types are derived.""" @@ -32,7 +35,7 @@ class InverterType(ComponentType): def _component_type_from_protobuf( component_category: components_pb.ComponentCategory.ValueType, component_metadata: inverter_pb.Metadata, -) -> Optional[ComponentType]: +) -> ComponentType | None: """Convert a protobuf InverterType message to Component enum. For internal-only use by the `microgrid` package. @@ -106,13 +109,39 @@ def _component_category_from_protobuf( return ComponentCategory(component_category) +@dataclass(frozen=True) +class ComponentMetadata: + """Base class for component metadata classes.""" + + fuse: Fuse | None = None + """The fuse at the grid connection point.""" + + +@dataclass(frozen=True) +class GridMetadata(ComponentMetadata): + """Metadata for a grid connection point.""" + + +def _component_metadata_from_protobuf( + component_category: components_pb.ComponentCategory.ValueType, + component_metadata: grid_pb.Metadata, +) -> GridMetadata | None: + if component_category == components_pb.ComponentCategory.COMPONENT_CATEGORY_GRID: + max_current = Current.from_amperes(component_metadata.rated_fuse_current) + fuse = Fuse(max_current) + return GridMetadata(fuse) + + return None + + @dataclass(frozen=True) class Component: """Metadata for a single microgrid component.""" component_id: int category: ComponentCategory - type: Optional[ComponentType] = None + type: ComponentType | None = None + metadata: ComponentMetadata | None = None def is_valid(self) -> bool: """Check if this instance contains valid data. @@ -125,6 +154,14 @@ def is_valid(self) -> bool: self.component_id > 0 and any(t == self.category for t in ComponentCategory) ) or (self.component_id == 0 and self.category == ComponentCategory.GRID) + def __hash__(self) -> int: + """Compute a hash of this instance, obtained by hashing the `component_id` field. + + Returns: + Hash of this instance. + """ + return hash(self.component_id) + class ComponentMetricId(Enum): """An enum representing the various metrics available in the microgrid.""" diff --git a/src/frequenz/sdk/microgrid/fuse.py b/src/frequenz/sdk/microgrid/fuse.py new file mode 100644 index 000000000..f5c80261f --- /dev/null +++ b/src/frequenz/sdk/microgrid/fuse.py @@ -0,0 +1,16 @@ +# License: MIT +# Copyright © 2023 Frequenz Energy-as-a-Service GmbH + +"""Fuse data class.""" + +from dataclasses import dataclass + +from ..timeseries import Current + + +@dataclass(frozen=True) +class Fuse: + """Fuse data class.""" + + max_current: Current + """Rated current of the fuse.""" diff --git a/src/frequenz/sdk/microgrid/grid.py b/src/frequenz/sdk/microgrid/grid.py new file mode 100644 index 000000000..b0ae3cd3e --- /dev/null +++ b/src/frequenz/sdk/microgrid/grid.py @@ -0,0 +1,80 @@ +# License: MIT +# Copyright © 2023 Frequenz Energy-as-a-Service GmbH + +"""Grid connection point. + +This module provides the `Grid` type, which represents a grid connection point +in a microgrid. +""" + +import logging +from collections.abc import Iterable +from dataclasses import dataclass + +from .component import Component +from .component._component import ComponentCategory +from .fuse import Fuse + + +@dataclass(frozen=True) +class Grid: + """A grid connection point.""" + + fuse: Fuse + """The fuse protecting the grid connection point.""" + + +_GRID: Grid | None = None + + +def initialize(components: Iterable[Component]) -> None: + """Initialize the grid connection. + + Args: + components: The components in the microgrid. + + Raises: + RuntimeError: If there is more than 1 grid connection point in the + microgrid, or if the grid connection point is not initialized, + or if the grid connection point does not have a fuse. + """ + global _GRID # pylint: disable=global-statement + + grid_connections = list( + component + for component in components + if component.category == ComponentCategory.GRID + ) + + grid_connections_count = len(grid_connections) + + if grid_connections_count == 0: + logging.info( + "No grid connection found for this microgrid. This is normal for an islanded microgrid." + ) + elif grid_connections_count > 1: + raise RuntimeError( + f"Expected at most one grid connection, got {grid_connections_count}" + ) + else: + if grid_connections[0].metadata is None: + raise RuntimeError("Grid metadata is None") + + fuse = grid_connections[0].metadata.fuse + + if fuse is None: + raise RuntimeError("Grid fuse is None") + + _GRID = Grid(fuse) + + +def get() -> Grid | None: + """Get the grid connection. + + Note that a microgrid configured as an island will not have a grid + connection point. For such microgrids, this function will return `None`. + + Returns: + The grid connection. + """ + return _GRID diff --git a/tests/microgrid/mock_api.py b/tests/microgrid/mock_api.py index 37d5bb86f..3e07b3824 100644 --- a/tests/microgrid/mock_api.py +++ b/tests/microgrid/mock_api.py @@ -30,6 +30,7 @@ from frequenz.api.microgrid.battery_pb2 import Battery from frequenz.api.microgrid.battery_pb2 import Data as BatteryData from frequenz.api.microgrid.ev_charger_pb2 import EvCharger +from frequenz.api.microgrid.grid_pb2 import Metadata as GridMetadata from frequenz.api.microgrid.inverter_pb2 import Inverter from frequenz.api.microgrid.inverter_pb2 import Metadata as InverterMetadata from frequenz.api.microgrid.meter_pb2 import Data as MeterData @@ -57,6 +58,8 @@ from google.protobuf.timestamp_pb2 import Timestamp from google.protobuf.wrappers_pb2 import BoolValue +from frequenz.sdk.timeseries import Current + class MockMicrogridServicer( # pylint: disable=too-many-public-methods MicrogridServicer @@ -89,6 +92,7 @@ def add_component( self, component_id: int, component_category: ComponentCategory.V, + max_current: Optional[Current] = None, inverter_type: InverterType.V = InverterType.INVERTER_TYPE_UNSPECIFIED, ) -> None: """Add a component to the mock service.""" @@ -100,6 +104,17 @@ def add_component( inverter=InverterMetadata(type=inverter_type), ) ) + elif ( + component_category == ComponentCategory.COMPONENT_CATEGORY_GRID + and max_current is not None + ): + self._components.append( + Component( + id=component_id, + category=component_category, + grid=GridMetadata(rated_fuse_current=int(max_current.as_amperes())), + ) + ) else: self._components.append( Component(id=component_id, category=component_category) diff --git a/tests/microgrid/test_client.py b/tests/microgrid/test_client.py index 853d183ad..8b38e72bb 100644 --- a/tests/microgrid/test_client.py +++ b/tests/microgrid/test_client.py @@ -21,10 +21,13 @@ Component, ComponentCategory, EVChargerData, + GridMetadata, InverterData, InverterType, MeterData, ) +from frequenz.sdk.microgrid.fuse import Fuse +from frequenz.sdk.timeseries import Current from . import mock_api @@ -106,7 +109,6 @@ async def test_components(self) -> None: 100, components_pb.ComponentCategory.COMPONENT_CATEGORY_UNSPECIFIED, ), - (101, components_pb.ComponentCategory.COMPONENT_CATEGORY_GRID), (104, components_pb.ComponentCategory.COMPONENT_CATEGORY_METER), (105, components_pb.ComponentCategory.COMPONENT_CATEGORY_INVERTER), (106, components_pb.ComponentCategory.COMPONENT_CATEGORY_BATTERY), @@ -117,9 +119,24 @@ async def test_components(self) -> None: (999, components_pb.ComponentCategory.COMPONENT_CATEGORY_SENSOR), ] ) + + servicer.add_component( + 101, + components_pb.ComponentCategory.COMPONENT_CATEGORY_GRID, + Current.from_amperes(123.0), + ) + + grid_max_current = Current.from_amperes(123.0) + grid_fuse = Fuse(grid_max_current) + assert set(await microgrid.components()) == { Component(100, ComponentCategory.NONE), - Component(101, ComponentCategory.GRID), + Component( + 101, + ComponentCategory.GRID, + None, + GridMetadata(fuse=grid_fuse), + ), Component(104, ComponentCategory.METER), Component(105, ComponentCategory.INVERTER, InverterType.NONE), Component(106, ComponentCategory.BATTERY), @@ -136,6 +153,7 @@ async def test_components(self) -> None: servicer.add_component( 99, components_pb.ComponentCategory.COMPONENT_CATEGORY_INVERTER, + None, components_pb.InverterType.INVERTER_TYPE_BATTERY, ) diff --git a/tests/microgrid/test_graph.py b/tests/microgrid/test_graph.py index a58bdb769..7d6f35640 100644 --- a/tests/microgrid/test_graph.py +++ b/tests/microgrid/test_graph.py @@ -18,7 +18,14 @@ import frequenz.sdk.microgrid._graph as gr from frequenz.sdk.microgrid.client import Connection, MicrogridGrpcClient -from frequenz.sdk.microgrid.component import Component, ComponentCategory, InverterType +from frequenz.sdk.microgrid.component import ( + Component, + ComponentCategory, + GridMetadata, + InverterType, +) +from frequenz.sdk.microgrid.fuse import Fuse +from frequenz.sdk.timeseries import Current from .mock_api import MockGrpcServer, MockMicrogridServicer @@ -816,8 +823,19 @@ async def test_refresh_from_api(self) -> None: servicer.set_connections([(101, 111), (111, 131)]) await graph.refresh_from_api(client) + grid_max_current = Current.zero() + grid_fuse = Fuse(grid_max_current) + + # Note: we need to add GriMetadata as a dict here, because that's what + # the ComponentGraph does too, and we need to be able to compare the + # two graphs. expected = { - Component(101, ComponentCategory.GRID), + Component( + 101, + ComponentCategory.GRID, + None, + asdict(GridMetadata(fuse=grid_fuse)), # type: ignore + ), Component(111, ComponentCategory.METER), Component(131, ComponentCategory.EV_CHARGER), } @@ -842,8 +860,14 @@ async def test_refresh_from_api(self) -> None: ) servicer.set_connections([(707, 717), (717, 727), (727, 737), (717, 747)]) await graph.refresh_from_api(client) + expected = { - Component(707, ComponentCategory.GRID), + Component( + 707, + ComponentCategory.GRID, + None, + asdict(GridMetadata(fuse=grid_fuse)), # type: ignore + ), Component(717, ComponentCategory.METER), Component(727, ComponentCategory.INVERTER, InverterType.NONE), Component(737, ComponentCategory.BATTERY), @@ -1146,7 +1170,7 @@ def test__validate_grid_endpoint(self) -> None: gr.InvalidGraphError, match=r"Grid endpoint 1 has graph predecessors: \[Component" r"\(component_id=99, category=, " - r"type=None\)\]", + r"type=None, metadata=None\)\]", ) as _err_predecessors: graph._validate_grid_endpoint() diff --git a/tests/microgrid/test_grid.py b/tests/microgrid/test_grid.py new file mode 100644 index 000000000..b401266c4 --- /dev/null +++ b/tests/microgrid/test_grid.py @@ -0,0 +1,68 @@ +# License: MIT +# Copyright © 2023 Frequenz Energy-as-a-Service GmbH + +""" +Tests for the `Grid` module. +""" + +from frequenz.sdk import microgrid +from frequenz.sdk.microgrid.component import Component, ComponentCategory, GridMetadata +from frequenz.sdk.microgrid.fuse import Fuse +from frequenz.sdk.microgrid.grid import Grid +from frequenz.sdk.timeseries import Current + + +async def test_grid() -> None: + """Test the grid connection module.""" + + # The tests here need to be in this exact sequence, because the grid connection + # is a singleton. Once it gets created, it stays in memory for the duration of + # the tests, unless we explicitly delete it. + + # validate that islands with no grid connection are accepted. + components = [ + Component(2, ComponentCategory.METER), + ] + + microgrid.grid.initialize(components) + + grid = microgrid.grid.get() + assert grid is None + + # validate that the microgrid initialization fails when there are multiple + # grid connection points. + fuse_current = Current.from_amperes(123.0) + fuse = Fuse(fuse_current) + + components = [ + Component(1, ComponentCategory.GRID, None, GridMetadata(fuse)), + Component(2, ComponentCategory.GRID, None, GridMetadata(fuse)), + Component(3, ComponentCategory.METER), + ] + + try: + microgrid.grid.initialize(components) + assert False, "Expected microgrid.grid.initialize to raise a RuntimeError." + except RuntimeError: + pass + + grid = microgrid.grid.get() + assert grid is None + + # validate that microgrids with one grid connection are accepted. + components = [ + Component(1, ComponentCategory.GRID, None, GridMetadata(fuse)), + Component(2, ComponentCategory.METER), + ] + + microgrid.grid.initialize(components) + + grid = microgrid.grid.get() + + expected_fuse_current = Current.from_amperes(123.0) + expected_fuse = Fuse(expected_fuse_current) + + assert grid == Grid(fuse=expected_fuse) + + fuse_current = grid.fuse.max_current + assert fuse_current == expected_fuse_current